Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -70,17 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — re-run any time.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -393,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -719,7 +719,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+12
-12
@@ -70,17 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),随时重跑都行。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -387,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -708,7 +708,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+134
-8
@@ -127,6 +127,15 @@ function installSkillsToHomes(skillRoot) {
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
// Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
// Directories only — a stray file named dingtalk-x.md must survive.
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && (entry.name.startsWith("dingtalk-") || entry.name === "dws-shared")) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
@@ -138,23 +147,122 @@ function installSkillsToHomes(skillRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale dingtalk-* skills not present
|
||||
// in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
let installed = 0;
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.rmSync(path.join(baseDir, "dws"), { recursive: true, force: true });
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(entry.name.startsWith("dingtalk-") || entry.name === "dws-shared") &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
const destDir = path.join(baseDir, name);
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(path.join(multiRoot, name), destDir);
|
||||
}
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
installToBase(baseDir);
|
||||
installed += 1;
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
installToBase(path.join(homeDir, ".agents", "skills"));
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
@@ -191,7 +299,25 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Skill 分发/安装架构优化方案
|
||||
|
||||
> 目标:把 DWS Agent skill 的"装在哪、怎么装、装完记什么"从 7+ 个安装面的
|
||||
> N 份漂移拷贝,收敛为 **Go 侧单一事实源 + 单一安装引擎 + 脚本侧 bootstrap**。
|
||||
> 本文基于 feat/skill-mode-migration 工作区(multi 化落地后)的代码级盘点
|
||||
> (2026-08-05),所有锚点均可跳转验证。
|
||||
> 前置文档:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)(下称《迁移计划》)、
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 1. 问题量化
|
||||
|
||||
### 1.1 复制矩阵(能力 × 面)
|
||||
|
||||
multi 化之后,同一块逻辑在各安装面的拷贝数(✓ = 独立拷贝一份,数字 = 份数):
|
||||
|
||||
| 能力 \ 面 | install.sh | install.ps1 | npm install.js | install-skills.sh | install-event.sh | install-devapp.sh(+ps1) | Homebrew caveats | `dws skill setup` (Go) | `dws upgrade` (Go) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| agent home 清单 | ✓×3 | ✓ | ✓ | ✓×2 | ✓ | ✓×2 | — | ✓ | ✓ |
|
||||
| 互斥清理(mono↔multi) | ✓×2(其中 1 份死代码) | ✓ | ✓ | ✓ | — | — | — | ✓ | ✓ |
|
||||
| mode 解析(env/flag/交互) | ✓ | ✓ | ✓ | — | — | — | — | ✓ | 布局嗅探 |
|
||||
| stale `dingtalk-*` 清理 | ✓ | ✓ | ✓ | ✓ | — | — | — | ✗(additive,不清) | ✓ |
|
||||
| `~/.dws/skills` 缓存写 | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | —(只读回退) | ✓(仅 multi) |
|
||||
| 安装状态(state.json) | 无 | 无 | 无 | 无 | 无 | 无 | — | 无 | 无 |
|
||||
|
||||
清单锚点(agent home 清单,全仓共 **15 份**):
|
||||
|
||||
| # | 位置 | 备注 |
|
||||
|---|---|---|
|
||||
| 1 | `internal/app/skill_setup.go:20-37` | `skillSetupAgentHomes`,16 项,**无 opencode** |
|
||||
| 2 | `internal/app/skill_command.go:119-141` | `agentSkillPaths`,17 项,含 opencode(`:129`) |
|
||||
| 3 | `internal/upgrade/paths.go:35-52` | `knownSkillDirs`,16 项,**无 opencode** |
|
||||
| 4 | `scripts/install.sh:370-386` | mono 安装循环内联清单 |
|
||||
| 5 | `scripts/install.sh:439-455` | multi 清单(**死代码**,见 1.2) |
|
||||
| 6 | `scripts/install.sh:516-532` | multi 清单(生效) |
|
||||
| 7 | `scripts/install.ps1:44-61` | `$AgentDirs` |
|
||||
| 8 | `build/npm/install.js:11-28` | `AGENT_DIRS` |
|
||||
| 9 | `scripts/install-skills.sh:166-182` | multi 清单 |
|
||||
| 10 | `scripts/install-skills.sh:247-261` | mono 清单 |
|
||||
| 11 | `scripts/install-event.sh:103-107` | 17 项,**含 `.config/opencode/skills`**(`:107`) |
|
||||
| 12 | `scripts/install-devapp.sh:87-91` | 含 opencode(`:91`) |
|
||||
| 13 | `scripts/install-devapp.ps1:106` | 含 opencode |
|
||||
| 14 | `test/scripts/package_script_test.go` | `expectedPackagedSkillTargets` |
|
||||
| 15 | `scripts/release/verify-package-managers.sh:75-76` | `HOME_AGENT_PARENTS` / `HOME_SKILL_TARGETS` |
|
||||
|
||||
互斥清理逻辑共 **≥8 份**:`skill_setup.go:570-608`、`paths.go:306-327` +
|
||||
`paths.go:163-179`(mono 分支内联)、`install.sh:394-399` + `install.sh:557-570`
|
||||
+ 死代码 `install.sh:477-486`、`install.ps1:488-498` + `install.ps1:562-570`、
|
||||
`install.js:130-137` + `install.js:165-178`、`install-skills.sh:207-220`。
|
||||
|
||||
mode 解析共 **4 份**:`skill_setup.go:343-376`、`install.sh:220-257`、
|
||||
`install.ps1:293-332`、`install.js:197-214`(各自实现 env 优先级、非法值报错、
|
||||
TTY 交互、非 TTY 默认值,措辞与行为细节已不完全一致)。
|
||||
|
||||
stale `dingtalk-*` 清理共 **5 份**:`paths.go:317-325`、`install.sh:561-567`、
|
||||
`install.ps1:562-570`、`install.js:168-172`、`install-skills.sh:211-220`。
|
||||
|
||||
缓存写共 **6 份**:`install.sh:326-344`(multi)+ `install.sh:349-360`(mono)、
|
||||
`install.ps1:446-471`、`install.js:221-237`、`install-event.sh:165-169`、
|
||||
`install-devapp.sh:82-84`、`paths.go:290-296`(upgrade 只刷 multi)。
|
||||
|
||||
### 1.2 已发生的漂移实例(不是假设,是现状)
|
||||
|
||||
1. **opencode 清单漂移**:`dws skill install` 支持 opencode 目标
|
||||
(`skill_command.go:129`),install-event / install-devapp 也往
|
||||
`~/.config/opencode/skills` 装(`install-event.sh:107`、
|
||||
`install-devapp.sh:91`),但 `dws skill setup --target all` 与
|
||||
`dws upgrade` 的清单都不含它(`skill_setup.go:20-37`、
|
||||
`paths.go:35-52`)。结果:opencode 用户能收到 event/dev skill,
|
||||
却永远收不到内置 skill 的安装与升级。
|
||||
2. **install.sh 死代码**:`install_multi_skills_to_homes` 定义了两次
|
||||
(`install.sh:434` 与 `install.sh:511`),`_install_multi_to_base`
|
||||
同样两次(`install.sh:473` 与 `install.sh:549`)。bash 后定义覆盖先定义,
|
||||
第一对(434-505)整体是死代码——本次 multi 化自己引入的重复。
|
||||
3. **"镜像"注释与实现已不符**:`install.sh:507-510` 注释声称 multi 安装
|
||||
"mirroring `dws skill setup --mode multi`",但脚本会删除 bundle 里不存在
|
||||
的 stale `dingtalk-*`(`install.sh:561-567`),而 setup 是 additive 语义、
|
||||
只清 `dws/`(`skill_setup.go:587-593`)。注释说镜像,行为已分叉。
|
||||
4. **互斥语义自相矛盾**:install-event.sh 把 multi 的 `dingtalk-event` 和
|
||||
mono 的 `dws` **同时**装进同一批 agent home(`install-event.sh:171-172`),
|
||||
与其他所有面"mono/multi 互斥"的语义直接冲突。
|
||||
5. **缓存只写不读、版本不可见**:`dws skill setup` 默认走二进制 embed
|
||||
(`skill_setup_embed.go:50-59`),`~/.dws/skills` 只是 legacy 回退候选
|
||||
(`skill_setup.go:442-447`);6 个写入方没有任何版本戳,upgrade 只刷
|
||||
multi 不刷 mono(`paths.go:292-296`),缓存与 embed 漂移不可见。
|
||||
6. **保留前缀规则已双份**:`dingtalk-` / `dws-shared` 的保留约定在
|
||||
`skill_setup.go:199`(`multiSkillPrefix`)、`skill_setup.go:205`
|
||||
(`multiSharedSkill`)与 `paths.go:332-334`(`isMultiSkillDirName`)
|
||||
各写一份,互斥清理依赖"市场 skill 不用该前缀"这一隐性约定。
|
||||
|
||||
### 1.3 维护成本论证(本次 multi 化实证)
|
||||
|
||||
本次"把 7 个面全部 multi 化"这一个语义变更,工作区改动为 **13 个文件、
|
||||
+759/−166 行**(`git diff --stat`),横跨 Go / POSIX sh / PowerShell /
|
||||
JavaScript 四种语言:
|
||||
|
||||
| 文件 | 改动量 | 改了什么 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | +256/−… | mode 解析、multi 安装×2(含死代码)、互斥清理、缓存写 |
|
||||
| `scripts/install.ps1` | +169 | 同上,PowerShell 再写一遍 |
|
||||
| `build/npm/install.js` | +87 | 同上,JavaScript 再写一遍 |
|
||||
| `scripts/install-skills.sh` | +101 | multi 安装 + stale 清理 |
|
||||
| `internal/upgrade/paths.go` | +206 | upgrade multi 刷新 + 互斥清理 + 缓存 |
|
||||
| `internal/app/skill_setup.go` 等 Go 侧 | ~+50 | setup multi 语义 |
|
||||
| 测试与文档 | 其余 | 4 个测试文件 + README×2 + SKILL.md |
|
||||
|
||||
即:**一个语义变更 = 4 种语言 × 7+ 处同步编辑**,且仍然漏了 opencode、
|
||||
制造了死代码、留下了语义分叉(1.2)。新增一个 agent home 今天要改 15 份
|
||||
清单中的至少 11 份(另 4 份是测试/校验)。这不是"以后会漂移",而是
|
||||
**每一次改动都在当场制造漂移**。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 分层总览
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ L3 分发面(7 个,全部退化为 bootstrap) │
|
||||
│ install.sh / install.ps1 / npm install.js / install-skills.sh /│
|
||||
│ install-event.sh / install-devapp.sh / Homebrew caveats │
|
||||
│ 职责:装二进制 → exec `dws skill setup --mode X --yes` │
|
||||
│ └─ 失败 → 冻结的内嵌 fallback 拷贝(一档,不再演进) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L2 单一安装引擎(Go,唯二入口) │
|
||||
│ dws skill setup ─┐ │
|
||||
│ dws upgrade ─┴─► 共用同一 install 实现(含互斥清理/记账) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L1 单一事实源:internal/skillhome(新共享包) │
|
||||
│ AgentHomes() 清单 + 布局规则(mono→<home>/dws,multi→平铺) │
|
||||
│ + 互斥/ stale 清理规则 + 保留前缀常量 + state.json 读写 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L0 事实数据 │
|
||||
│ //go:embed skills/{mono,multi}(skills_embed.go:28,默认源) │
|
||||
│ ~/.dws/skills/state.json(权威安装状态) │
|
||||
│ ~/.dws/skills/{mono,multi}(显式回退源,带版本戳,唯一写方=Go) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ 门禁:scripts/policy/check-agent-homes-sync.sh(进 make policy)
|
||||
比对各脚本可解析清单块 ↔ skillhome 导出清单
|
||||
```
|
||||
|
||||
### 2.2 单一事实源:`internal/skillhome` 共享包
|
||||
|
||||
新建 `internal/skillhome`,把今天散在 3 处 Go 代码里的规则合并导出,
|
||||
setup / upgrade / 测试共用(对应《迁移计划》P0c-1):
|
||||
|
||||
| 导出物 | 收敛的现有拷贝 |
|
||||
|---|---|
|
||||
| `AgentHomes()`(含 opencode,带"首项必装/其余父目录门控"元数据) | `skill_setup.go:20-37`、`paths.go:35-52`,并与 `skill_command.go:119-141` 互相断言 |
|
||||
| `HomeForMode(base, mode)` 布局规则 | `skill_setup.go:502-507`(`agentHomeForMode`) |
|
||||
| `MutualExclusionVictims(home, mode)` / stale 判定 | `skill_setup.go:570-596`、`paths.go:306-327` |
|
||||
| `ReservedSkillPrefixes` / `ReservedSkillNames` 常量 | `skill_setup.go:199/205`、`paths.go:332-334` |
|
||||
| `State` 读写(state.json,见 2.4) | 新增(《迁移计划》P0b-1) |
|
||||
|
||||
脚本侧不生成代码(sh/ps1/js 三语言片段生成成本高、措辞差异大),改为
|
||||
**可解析标记块 + policy 比对**:每个脚本的清单包在
|
||||
`# DWS-AGENT-HOMES-BEGIN` / `# DWS-AGENT-HOMES-END` 注释块内,新增
|
||||
`scripts/policy/check-agent-homes-sync.sh` 提取 7 个脚本块与
|
||||
`skillhome` 导出清单(`go run ./internal/skillhome/cmd/dump` 或
|
||||
`dws skill setup --print-agent-homes` 之类调试出口)逐一比对,挂进
|
||||
`make policy`(Makefile:86-97 现有政策链)。对应《迁移计划》P0c-2。
|
||||
|
||||
### 2.3 安装逻辑单引擎化:脚本退化为 bootstrap
|
||||
|
||||
**终态形态**:每个安装脚本只做两件事——装二进制、执行
|
||||
`dws skill setup --mode <resolved> --yes`(mode 解析仍允许脚本做,因为它
|
||||
要处理自己的 env/flag;也可以更进一步把 `DWS_SKILL_MODE` 透传给 setup)。
|
||||
执行失败(二进制跑不起来、setup 非 0 退出)时,降级到**冻结的内嵌
|
||||
fallback 拷贝逻辑一档**——即今天的拷贝实现原样保留但标记"不再演进",
|
||||
语义变更只改 Go 引擎。
|
||||
|
||||
各面降级可行性分析:
|
||||
|
||||
| 面 | 二进制可得性 | 可行性 | 风险与对策 |
|
||||
|---|---|---|---|
|
||||
| install.sh | `main` 先 `install_binary` 后 `install_skills`(`install.sh:830-831`),二进制就在 `$INSTALL_DIR` | ✅ 直接调 `"$INSTALL_DIR/dws" skill setup --mode "$SKILL_MODE" --yes` | `DWS_SKILLS_ONLY=1` 时不装二进制 → 先 `command -v dws`,无则走 fallback。curl 下载不带 quarantine 属性,macOS 可直接执行。风险低 |
|
||||
| install.ps1 | 同序(`Install-Binary`:337 → `Install-Skills`:622,main 入口 `:700`) | ✅ 调 `& $installDir\dws.exe skill setup ...` | ExecutionPolicy 约束的是 .ps1 脚本本身,**子进程 dws.exe 不受其限制**;AppLocker/WDAC 环境可能拦未签名二进制——但那种环境 dws 本身也跑不了,fallback 拷贝仍必要。风险低-中 |
|
||||
| npm install.js | postinstall 已把平台二进制解到 `vendor/`(`install.js:260`)再装 skill(`:271-280`) | ✅ `execFileSync(path.join(vendorDir,'dws'), ['skill','setup','--mode',m,'--yes'])` | `npm i --ignore-scripts` 时 postinstall 整体不跑(现状如此,非新增风险);部分 CI 以 root 跑生命周期脚本权限怪异;Windows 用 `dws.exe`。风险中,fallback 必须保留 |
|
||||
| install-skills.sh | skills-only 面,不装二进制 | ✅ `command -v dws` 有则调引擎,无则 fallback | 本质是"刷新 skill"路径,有 dws 才谈得上刷新。风险低 |
|
||||
| install-event.sh | 装二进制 + 单 skill(dingtalk-event + mono dws) | ⚠️ 有条件 | `EVENT_VERSION` 可与已装二进制版本不同(尤其 `DWS_SKILLS_ONLY=1`),embed 与目标 zip 可能错配;且当前同时装 mono+multi 的语义(`:171-172`)需先按 1.2-4 收敛。**最后迁移**,迁移前先把 dingtalk-event 纳入 embed 并改成 `-s event` 调引擎 |
|
||||
| install-devapp.sh(+ps1) | 同上(dingtalk-dev) | ⚠️ 有条件 | 同 install-event。风险中-高,最后迁移 |
|
||||
| Homebrew caveats | 只打印提示 | ✅ 已是终态 | `homebrew.rb.tmpl:33-36` 现状就是"Run `dws skill setup`",无需改,是其他面的样板 |
|
||||
|
||||
配套收益:《迁移计划》P2-2(sh/ps1 的 multi 分支真装,1.5d)与 P2-3
|
||||
(install.js / install-skills.sh 加 mode 支持,1d)在单引擎方案下**大幅
|
||||
缩水**——脚本只需把 mode 透传给引擎,不再在 sh/ps1/js 里写安装逻辑。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **state.json 权威化**(《迁移计划》P0b 原样采纳):
|
||||
`~/.dws/skills/state.json` 记录 `schema_version / mode / cli_version /
|
||||
installed / agent_homes / previous`,写方只有 `dws skill setup` 与
|
||||
`dws upgrade`(脚本侧不再各自写状态——它们调引擎,引擎记账);缺失/损坏
|
||||
时按磁盘形态反推(有 `dws/` → mono;有 `dingtalk-*` → multi;都有 →
|
||||
报 drift 要求显式收敛)。
|
||||
- **`~/.dws/skills` 缓存收敛**:二选一,本文建议后者——
|
||||
1. ~~被 state 取代,删除缓存~~:激进;embed 之外的"无源码机回退源"场景
|
||||
(`skill_setup.go:442-447` 注释描述的场景)会断。
|
||||
2. **明确为 setup 回退源 + 版本戳**(采纳):缓存目录写入
|
||||
`cli_version` 戳文件(或并入 state.json 的 `cache_version` 字段);
|
||||
**写缓存收敛到 Go 侧唯一实现**(setup/upgrade 共用),install.sh /
|
||||
ps1 / js / event / devapp 的 6 份缓存写逻辑(1.1 矩阵)随 P1/P3 删除;
|
||||
upgrade 补齐 mono 缓存刷新(修 `paths.go:292-296` 只刷 multi 的不对称)。
|
||||
mono 下线版本再评估整体废弃缓存。
|
||||
|
||||
### 2.5 市场 skill 边界正式化
|
||||
|
||||
把"`dingtalk-*` 前缀与 `dws-shared` / `dws` 名称为 DWS 内置保留"从隐性约定
|
||||
变成共享常量 + 测试:
|
||||
|
||||
- `skillhome.ReservedSkillPrefixes = ["dingtalk-"]`、
|
||||
`skillhome.ReservedSkillNames = ["dws-shared", "dws"]`,替换
|
||||
`skill_setup.go:199/205` 与 `paths.go:332-334` 两份私有拷贝;所有互斥/
|
||||
stale 清理只认这两个常量。
|
||||
- 测试一:扫描 `skills/multi/` 实际目录名,断言全部命中保留规则(防新增
|
||||
产品 skill 破坏前缀约定)。
|
||||
- 测试二:市场 skill 安装路径(`skill_command.go:488-498` 的目标解析)若
|
||||
产物名命中保留名则拒绝安装——把"市场无同名前缀"从祈祷变成门禁。
|
||||
|
||||
## 3. 迁移步骤(渐进、不破坏存量用户)
|
||||
|
||||
| 阶段 | 内容 | 风险 |
|
||||
|---|---|---|
|
||||
| **P0 清单 + policy** | 建 `internal/skillhome`:AgentHomes(**补 opencode**,修 1.2-1)、布局/互斥/保留名常量;setup/upgrade 切到共享包;脚本清单包标记块;新增 `check-agent-homes-sync.sh` 进 `make policy`;删 install.sh 死代码(1.2-2) | 低。纯收敛不改行为;policy 脚本初期可能误报 → 先 warn-only 跑一个版本再转 hard-fail |
|
||||
| **P1 setup 单引擎 + 脚本降级** | setup/upgrade 安装实现合一(互斥清理、stale 语义统一为"引擎内一种",修 1.2-3);install.sh / install.ps1 / install.js / install-skills.sh 改为 bootstrap + 冻结 fallback;install-event/devapp 暂不动 | 中。bootstrap 首版要在三语言 CI 矩阵上验证"引擎失败→fallback"链路;fallback 标记冻结,防止继续演进出第 9 份拷贝 |
|
||||
| **P2 state 权威 + 缓存收敛** | state.json 读写进 skillhome,setup/upgrade 记账;缓存加版本戳、写方收敛到 Go、upgrade 补刷 mono 缓存;install-event/devapp 语义收敛(mono+multi 双装改为引擎语义)后同样 bootstrap 化 | 中。存量机器无 state → 磁盘反推逻辑必须有故障注入测试;event/devapp 版本错配场景需保留 zip 直装逃生门 |
|
||||
| **P3 删除遗留拷贝** | 删除:脚本侧 6 份缓存写、install.sh/ps1/js 内被引擎接管的安装函数(保留冻结 fallback 一档)、Go 侧 `mutualExclusionVictims` 等被 skillhome 吸收的私有拷贝;观察一个版本后评估 fallback 是否可再降档 | 低-中。每删一处先确认 policy 与测试不再引用;fallback 删除是独立决策,不与本阶段捆绑 |
|
||||
|
||||
与《迁移计划》的先后关系:本文 P0 即计划 P0c,应**最先做**;P1 与计划
|
||||
P0a/P0b 并行不冲突(引擎合一会让 P0a 的 mode-aware upgrade 少写一份代码);
|
||||
P2 吸收计划 P0b;P3 在计划 P2 切默认之后执行。
|
||||
|
||||
## 4. 明确不做
|
||||
|
||||
- **不重写成 symlink canonical**(canonical 一份 + 各 agent home 软链):
|
||||
已随生态分发通道一并评估否决,决策与实测原因见
|
||||
《迁移计划》[§7.3](skill-multi-migration-plan.md)(无版本固定、依赖
|
||||
Node/GitHub 可达、mono 会被一起发现、悟空 bundled/离线预装覆盖不了;
|
||||
其中悟空分发线已于 2026-08-05 下线,该条约束随之消失)。
|
||||
本地 symlink 模式(`setup --link`)同样维持计划 §8.2"可选/后置"定位,
|
||||
不在本方案内。
|
||||
- **不动 `dws-skills.zip` 产物布局**:zip 根 mono 副本 + `mono/` + `multi/`
|
||||
双树保持不变(`scripts/release/post-goreleaser.sh:220-248`)。
|
||||
- **不动市场 skill**:`dws skill install` 的安装语义、目标解析
|
||||
(`skill_command.go:488-498`)不变;2.5 只新增保留名拒绝门禁与测试,
|
||||
不改变既有安装行为。
|
||||
- **不删 fallback**:脚本侧内嵌拷贝逻辑降级为冻结档保留,不追求"脚本零
|
||||
拷贝"的纯净化。
|
||||
|
||||
## 5. 收益/成本与任务映射
|
||||
|
||||
### 5.1 收益/成本表
|
||||
|
||||
| 项 | 现状 | 目标 | 量化收益 |
|
||||
|---|---|---|---|
|
||||
| agent home 清单 | 15 份拷贝,已漂移(opencode) | 1 份 Go 源 + policy 比对 | 新增 agent 从"改 11 处"变"改 1 处" |
|
||||
| 安装/互斥/stale 逻辑 | ≥8 份,4 种语言,语义已分叉 | 1 个 Go 引擎 | 语义变更从 4 语言 × 7 面(本次实证 13 文件 +759 行)变 1 包 |
|
||||
| mode 解析 | 4 份,行为细节不一 | 脚本只做透传,引擎一处实现 | 非法值/默认值行为天然一致 |
|
||||
| 缓存写 | 6 份,无版本戳,只写不读 | Go 唯一写方 + 版本戳 | 缓存漂移可观测、可判废 |
|
||||
| 安装状态 | 无 | state.json 权威 | upgrade 粘性、回滚、drift 检测的前提 |
|
||||
| 市场边界 | 隐性约定,2 份前缀拷贝 | 共享常量 + 2 个测试 | 互斥清理误伤市场 skill 的风险归零 |
|
||||
| 成本 | — | P0≈1.5d,P1≈3-4d,P2≈2d,P3≈1d | 合计 7.5-8.5 人日;其中 P0/P2 与《迁移计划》P0b/P0c 重叠,净新增约 4-5 人日 |
|
||||
|
||||
### 5.2 与《迁移计划》§8 任务 ID 映射
|
||||
|
||||
| 本文阶段 | 对应计划任务 | 关系 |
|
||||
|---|---|---|
|
||||
| P0 清单 + policy | P0c-1(清单下沉共享包+补 opencode)、P0c-2(check-agent-homes-sync.sh) | 原样采纳,包名定为 `internal/skillhome` |
|
||||
| P1 引擎合一 | P0a-1(UpgradeSkillLocations mode-aware)的前置简化 | 引擎合一后 P0a-1 不复写互斥/清理逻辑 |
|
||||
| P1 脚本 bootstrap | **取代** P2-2(sh/ps1 multi 真装,1.5d)、P2-3(install.js/install-skills.sh mode 支持,1d) | 脚本不再写安装逻辑,两个任务缩水为"透传 mode + 调引擎 + 冻结 fallback",合计从 2.5d 降至 ~1d |
|
||||
| P2 state 权威 | P0b-1(state.json schema+setup 写入)、P0b-2(磁盘反推) | 原样采纳;缓存版本戳与写方收敛为本文新增 |
|
||||
| P2 event/devapp 收敛 | 新增(修 1.2-4 语义矛盾) | 依赖 P1 引擎稳定 |
|
||||
| P3 删除遗留 | 新增 | 在计划 P2 切默认之后执行 |
|
||||
| (不在本文) | P0-3 备份式安装、P0-4 请求头、P1-1 `dws skill mode`、P1-2/P2-1 默认翻转、P2-4 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
|
||||
@@ -0,0 +1,231 @@
|
||||
# Skill 能力缺口分析与补全计划
|
||||
|
||||
> 基于 `feat/skill-mode-migration` 工作区代码级盘点(2026-08-05)。本工作区已把
|
||||
> install / upgrade / setup 的默认形态翻转为 multi;本文回答两个问题:
|
||||
> **管理能力上还缺什么**(Part 1)、**multi 内容是否覆盖 mono**(Part 2),
|
||||
> 并给出优先级排序的实施清单(Part 3)。
|
||||
> 任务 ID 复用 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §8.2
|
||||
> (P0a/P0b/P0c/P0-3/P0-4/P1-1/P1-2/P2-x/W4);新增项以 `P1-3`、`C1–C7` 编号。
|
||||
> 机制背景见 [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
- **管理面**:默认翻转已落地(setup/四个安装脚本/upgrade 识 multi),但生命周期
|
||||
能力仍缺 6/8 项:无状态(state.json)、无备份回滚、无卸载、无故障恢复、
|
||||
upgrade 不按已装清单做增量、市场/内置边界靠前缀约定。当前形态 = "能装上
|
||||
multi,但装成什么样、出了事怎么退,全靠运气"。
|
||||
- **内容面**:multi 对产品参考的覆盖**总体是 mono 超集**(chat/event/dev/sheet
|
||||
均更详细),但 mono 有 **4 块全局能力在 multi 完全缺失**(recovery 闭环、
|
||||
确认门禁协议、Schema 渐进查询教学、LICENSE/NOTICE),另有 1 个脚本
|
||||
(`report_inbox_today.py`)未迁移;multi 自身还有 6 项内部不一致(死链、
|
||||
orphan 脚本、漏改 EXPERIMENTAL 文案等)。
|
||||
- **工作量**:管理面 P0–P2 剩余 ≈ 10.5–13 人日;内容补全 C1–C7 ≈ 3.5–4 人日;
|
||||
合计 ≈ 14–17 人日,与 plan §8.1 的 13–17 人日口径一致(内容项是新增量)。
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — 管理能力缺口(skill 生命周期管理)
|
||||
|
||||
### 1.1 现状盘点(本工作区实际状态)
|
||||
|
||||
**`dws skill setup`(内置 skill 安装)**
|
||||
|
||||
| 能力 | 状态 | 锚点 |
|
||||
|---|---|---|
|
||||
| mode 选择 | mono / multi;无 `--mode` 时 TTY 交互(multi 为默认项)、非 TTY 默认 multi | `internal/app/skill_setup.go:343-376` |
|
||||
| 按产品挑选 | `-s/--skill`、`-x/--exclude` 互斥;`dws-shared` 强制包含;未点名的已有 `dingtalk-*` 保留(additive) | `skill_setup.go:101-102`、`254-318`、`209-226`、`640-670` |
|
||||
| `--dry-run` | 有,预览 mode/来源/目标/子 skill,不写文件(root 持久 flag 注入,`internal/app/flags.go:43`) | `skill_setup.go:156-167` |
|
||||
| 源 | `--source` / `DWS_SKILL_SOURCE` 显式覆盖(失败不回退)→ 默认 embed(与二进制同版) | `internal/app/skill_setup_embed.go:50-58`、`skills_embed.go:28` |
|
||||
| 目标 | 16 个 agent home,父目录门控;`--target all` 不含 opencode,但命名 target 含(不对称) | `skill_setup.go:20-37`、`509-522` vs `internal/app/skill_command.go:129` |
|
||||
| 互斥清理 | 装 mono 删 `dingtalk-*`+`dws-shared`,装 multi 删 `dws/`;**best-effort,失败仅 warning 继续装** | `skill_setup.go:570-608` |
|
||||
| 备份/记账 | **无**。`RemoveAll` 直删,不写任何状态 | `skill_setup.go:616`、`655` |
|
||||
|
||||
**`dws skill search / get / install`(市场 skill)**
|
||||
|
||||
- 子命令全集仅 `get / install / search / setup`(+ 隐藏的 find/add 兼容提示),
|
||||
**无 status / mode / remove / rollback**(`skill_command.go:202-209`)。
|
||||
- `install` 解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念、
|
||||
无记账、无覆盖保护(`skill_command.go:378-443`、`653-672`)。
|
||||
|
||||
**`dws upgrade`(skill 刷新)**
|
||||
|
||||
- `LocateSkillsRoot` **优先 zip 内 `multi/`**(`internal/upgrade/paths.go`,
|
||||
接线于 `internal/app/upgrade.go`);`UpgradeSkillLocations` **包驱动**:有
|
||||
multi 树则始终 multi 刷新,否则 legacy mono 回退(不做磁盘粘性)。
|
||||
- multi 路径:平铺刷新 `<agent>/dingtalk-*`+`dws-shared`,清 `dws/` 残留与过期
|
||||
`dingtalk-*`,best-effort 刷 `~/.dws/skills/multi`(sibling `mono/` 存在时
|
||||
亦刷 mono 缓存)。
|
||||
- **有 multi 包时存量 mono 一次性迁 multi**(2026-08-05 owner:升级不做粘性;
|
||||
非运行时 mode-switch 产品,无确认/备份/state)。
|
||||
- skill 安装发生在**二进制替换之后**,skill 失败时二进制已换 → 半升级态。
|
||||
`dws upgrade --rollback` 只回滚二进制。
|
||||
|
||||
**安装脚本(7 面)**
|
||||
|
||||
| 面 | 本工作区状态 |
|
||||
|---|---|
|
||||
| `scripts/install.sh` | 已默认 multi 且**真装**(`install_multi_skills_to_homes`),mono 为 opt-in;`rm -rf` 直删无备份 |
|
||||
| `scripts/install.ps1` | 同上(Windows) |
|
||||
| `scripts/install-skills.sh` | 已加 `DWS_SKILL_MODE`(默认 multi)+ multi 安装 |
|
||||
| `build/npm/install.js` | 已加 `installMultiSkillsToHomes`(互斥清理 + 平铺) |
|
||||
| Homebrew formula | 不铺 agent home,caveats 引导 `dws skill setup` |
|
||||
| `scripts/install-event.sh` / `install-devapp.sh` | 单 skill 专项语义,缓存 `multi/dingtalk-event` / `multi/dingtalk-dev` 子集 |
|
||||
| 共同缺口 | **均不写 state.json、均无备份**;agent home 清单仍是 sh/ps1/js/Go 多份手写(`paths.go:23-52` 的 keep-in-sync 注释约定,无门禁) |
|
||||
|
||||
### 1.2 缺口表(对照完整生命周期)
|
||||
|
||||
严重度:🔴 高(可致数据丢失/双份派发/半装态)|🟡 中(能力缺失但有绕行)|🟢 低(体验项)
|
||||
|
||||
| # | 生命周期项 | 现状 | 严重度 | 补全设计(复用 plan §8 ID) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **status 查询** | **无**。无 state.json、无 `dws skill mode/status`;判断 mode 只能人工看磁盘形态(`dws/` vs `dingtalk-*`) | 🔴 | **P0b-1** 新增 `~/.dws/skills/state.json`(schema_version/mode/cli_version/installed/agent_homes/previous),setup 与安装脚本写入;**P0b-2** 缺失/损坏时磁盘形态反推,双形态并存 → drift 报错;**P1-1** `dws skill mode`(status 子命令展示 mode/版本/已装列表/上次切换/备份) |
|
||||
| 2 | **切换** | 有(`setup --mode`),但**无状态、无记账**:切完不留 previous,互斥清理失败仅 warning 继续装(`skill_setup.go:600-608`)→ 可能 mono+multi 双份共存、Agent 双份派发 | 🔴 | **P0b** 记账(含 `previous`);**P0-3** 把「清理失败继续装」改为「失败整体回滚」;**P1-1** `mode set <mode>` 复用 setup 安装实现 + 备份 + 记账,成功后提示重启 AI 工具 |
|
||||
| 3 | **回滚** | **无备份**。setup/upgrade/安装脚本全部 `RemoveAll`/`rm -rf` 直删(`skill_setup.go:616,655`、`paths.go:181,249,307`、`install.js` `fs.rmSync`);`upgrade --rollback` 只回二进制 | 🔴 | **P0-3** 备份式安装:`RemoveAll` → `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/`,保留最近 2 份,任一 home 失败自动恢复并非 0 退出;**P1-1** `mode rollback` 一条命令回到 `state.previous` |
|
||||
| 4 | **版本对齐** | embed 天然同版(setup 默认源,`skill_setup_embed.go:50-58`)✓;但 `~/.dws/skills` 缓存**只写不读**(仅 legacy 回退候选,`skill_setup.go:445-447`),upgrade 只刷 multi 缓存不刷 mono(`paths.go:290-296`),漂移不可见 | 🟡 | **P0b-1** state.json 记 `cli_version` 使漂移可见;**P0a-1** upgrade 按 mode 同步刷新对应缓存(或评估废弃缓存,plan §6 风险表末行) |
|
||||
| 5 | **卸载** | **无 remove**。skill 子命令仅 get/install/search/setup(`skill_command.go:202-209`);用户只能手动删目录,且不知道该删哪些(16 个 home × N 个 skill) | 🟡 | **新增 P1-3** `dws skill remove`:按 state.json 的 `agent_homes`×`installed` 精确删除内置 skill(`dingtalk-*`+`dws-shared`+`dws/`),不动市场 skill;`--dry-run` 预览(依赖 P0b) |
|
||||
| 6 | **局部更新** | setup 侧 additive 语义完整(`-s/-x`,未点名保留);但 **upgrade 增量语义未按 `state.installed`**:`UpgradeSkillLocations` 用 zip 内 bundle 全集刷新(`paths.go:135-137`、`339-358`),用户 `-x` 排除过的 skill 会被升级装回来 | 🔴 | plan §8.3-1 已定死语义(以 `state.installed` 为准增量刷新、未装不补装);**P0b-1** 先落 `installed` 列表,**P0a-1** `UpgradeSkillLocations(dir, mode)` 按其过滤 |
|
||||
| 7 | **故障恢复** | **无**。setup 清理/拷贝失败仅 warning 或按 home 跳过,留半装态;upgrade 的 skill 失败发生在二进制替换之后(`upgrade.go:593-611`),半升级态无自动恢复、无修复命令 | 🔴 | **P0-3** 备份式安装 + 失败整体回滚(改变「warning 继续装」语义,需同步改 `skill_setup_full_coverage_test.go` 多处断言,plan §8.3-2);**P0b-2** drift 检测给显式收敛指令;**P1-1** `mode rollback` |
|
||||
| 8 | **市场 vs 内置边界** | **隐性前缀约定**:互斥清理按 `dingtalk-`/`dws-shared` 名称扫描(`paths.go:332-334`、`skill_setup.go:581`),无 SKILL.md frontmatter 校验 —— 市场 skill 若同名前缀会被误删;反向地,市场 `install` 解压无保护,可覆盖内置 `dingtalk-*`(`skill_command.go:653-672`) | 🟡 | plan §6 风险行:清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集并写成测试(落入 **P0-3** 的清理改造);长期由 state.json 的 `installed` 清单取代前缀扫描(P0b 后续) |
|
||||
|
||||
**附:本工作区已完成项**(不再列入缺口):setup 默认 multi(P2-1)、
|
||||
install.sh/ps1 真装 multi(P2-2)、install-skills.sh/install.js mode 支持
|
||||
(P2-3)、README×2 与 dingtalk-skill 文案(P2-4 部分)、upgrade 识别并刷新
|
||||
multi 包(P0a 的布局识别一半)。**尚未做**:P0c 清单收敛、P0b state.json、
|
||||
P0-3 备份、P0a 的 mode-aware 一半、P0-4 请求头、P1-1 mode 命令、P1-2 beta 轨。
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — 内容能力对等(mono vs multi)
|
||||
|
||||
### 2.1 树对比总览
|
||||
|
||||
| 维度 | mono | multi |
|
||||
|---|---|---|
|
||||
| 规模 | 152 文件 / ≈2.5 MB | 244 文件 / ≈3.3 MB(19 个 `dingtalk-*` + `dws-shared`) |
|
||||
| 入口 | 单 `SKILL.md`(332 行,含全局路由/危险表/Schema 教学) | 每产品一个 `SKILL.md` + `dws-shared` 全局契约(90 行) |
|
||||
| 全局参考 | `references/` 10 项(intent-guide、global-reference、url-patterns、error-codes、capability-limits、channel-login、field-rules、recovery-guide、best_practices/、products/) | `dws-shared/references/` 9 项 + 各产品 skill 自带 |
|
||||
| 脚本 | `scripts/` 37 个 | 10 个 skill 带 `scripts/` 共 55 个 |
|
||||
| 最佳实践 | `best_practices/` 01–11 + lite + `_common` | 按产品分发(01→chat … 11→minutes),`_common` 与 lite 入 `dws-shared` |
|
||||
|
||||
### 2.2 产品参考覆盖核对
|
||||
|
||||
逐产品比对结论:**multi 覆盖 mono 全部产品参考,且多数为超集**。
|
||||
|
||||
| mono 产品参考 | multi 对应物 | 结论 |
|
||||
|---|---|---|
|
||||
| aitable.md + aitable/(20)+ aitable-record-ops | `dingtalk-aitable`(同 20 子章节 + field-rules + 06-data-analytics) | ✅ 超集 |
|
||||
| chat.md + chat-emoji-list | `dingtalk-chat`(+ chat/ 5 个子章节) | ✅ 超集 |
|
||||
| calendar / contact / doc+doc/ / drive / mail / minutes / todo / wiki / aisearch / hrbrain / pat / markdown | 同名 `dingtalk-*` skill | ✅ 覆盖(doc 侧 mono 的 doc-file-ops/doc-list/doc-permission/doc-search 四篇已迁移为 `dingtalk-doc/references/doc.md:202-207` 的 drive/wiki 迁移表,属刻意重构) |
|
||||
| event.md(222 行) | `dingtalk-event`(event-im.md 399 行 + 完整订阅治理契约) | ✅ 超集 |
|
||||
| dev.md(212 行) | `dingtalk-dev`(12 篇 reference 共 584 行) | ✅ 超集(结构重组) |
|
||||
| oa / attendance / report / sheet / ding / devdoc / agoal | `dingtalk-misc` 对应 reference(sheet 多 3 篇:comment/formula/version) | ✅ 超集 |
|
||||
| simple.md(devdoc+oa 合集 + 意图判断 + 上下文传递表) | 已拆入 `dingtalk-misc/references/oa.md:309,425` 与 `devdoc.md:15,19` | ✅ 覆盖 |
|
||||
| 多组织/多账号(SKILL.md:64-70 一节) | `dingtalk-profile` 整个 skill | ✅ 超集 |
|
||||
| 意图决策树(SKILL.md:101-123) | `dws-shared/references/intent-guide.md`(536 行 ≥ mono 488 行)+ `routing.md` | ✅ 覆盖 |
|
||||
| best_practices 01–11 / lite / _common | 按产品分发 + `dws-shared/references/best_practices/_common/` | ✅ 覆盖 |
|
||||
| url-patterns / capability-limits / channel-login / error-codes | `dws-shared/references/` 同名 | ✅ 覆盖 |
|
||||
| field-rules.md(mono 全局位) | `dingtalk-aitable/references/field-rules.md` | ✅ 合理下沉(内容即 AI 表格字段规则) |
|
||||
|
||||
### 2.3 不对等项清单(mono 有、multi 无)
|
||||
|
||||
| # | 缺失项 | mono 锚点 | multi 现状证据 | 严重度 | 补齐方式(承载方) |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | **Recovery 闭环**(recovery-guide.md + global-reference §Recovery + 错误处理第 2 步) | `skills/mono/SKILL.md:296,311`、`references/recovery-guide.md`、`global-reference.md:62` | multi 全树 `RECOVERY_EVENT_ID` 零引用;`dws-shared/SKILL.md:83-89` 错误最短路径无 recovery;`dingtalk-dev/SKILL.md:124` 指向「root dws / dws-shared 的错误处理」→ **断链** | 🔴 | **dws-shared**:新增 `references/recovery-guide.md`(从 mono 移植)、SKILL.md 错误最短路径加 recovery 步、`global-reference.md` 补 Recovery 节(→ C1) |
|
||||
| M2 | **确认门禁协议 + 全局危险操作表** | `skills/mono/SKILL.md:137-187`(危险操作表 + 确认流程 + `confirmation_required` 识别与重试协议) | multi 仅 aitable/doc/misc 三个 SKILL.md 有产品级危险表;`confirmation_required` / 「确认门禁」全树零命中;`dws-shared/SKILL.md:37-38` 只有一行泛化规则 | 🔴 | **dws-shared**:全局确认门禁协议(识别 `confirmation_required`、原始命令追加 `--yes` 重试、`--dry-run` 预览、禁止管道喂答案)+ 危险操作索引指向各产品表(→ C2) |
|
||||
| M3 | **Schema 渐进查询教学** | `skills/mono/SKILL.md:198-292`(≈95 行:四层查询、`--compact`/`--all` 边界、字段速查、Schema/Help/业务数据边界表、漂移处理) | 各产品 SKILL.md 仅点状提及 leaf Schema(17 处);`dws-shared/references/global-reference.md:79-89`「命令自省」只有 `--help` | 🟡 | **dws-shared**:新增 Schema 渐进查询章节(改写为 multi 语境,链入渐进加载表)(→ C3) |
|
||||
| M4 | **scripts/report_inbox_today.py** | `skills/mono/scripts/report_inbox_today.py` | multi 无(misc 仅有 `report_received_today.py`);mono 文档也未引用它 | 🟢 | **dingtalk-misc**:先验证脚本仍可用 → 迁入 `scripts/` 并在 `report.md` 引用;不可用则连同 mono 侧一起删(→ C5) |
|
||||
| M5 | **LICENSE / NOTICE** | `skills/mono/LICENSE`、`NOTICE` | multi 20 个 skill 均无 | 🟡 | 每个 multi skill 根复制两份(或 release 打包期注入,`scripts/release/post-goreleaser.sh`)(→ C7) |
|
||||
| M6 | **aiapp 意图路由** | `skills/mono/SKILL.md:76,101`(产品表 + 决策树有 aiapp 行,但目标 `aiapp.md` 不存在 —— mono 自身死链) | multi 全树无 aiapp 路由/文档;仅 orphan 脚本 `dingtalk-misc/scripts/aiapp_create_and_poll.py` | 🟡 | 决策:**dws-shared/routing.md** + **dingtalk-misc** 产品索引补 aiapp 行并新建 reference,或明确下线该能力并清掉 mono 死链与 orphan 脚本(→ C5) |
|
||||
|
||||
### 2.4 multi 自身不一致项(不阻塞对等结论,但阻塞「multi 可独当一面」)
|
||||
|
||||
| # | 问题 | 证据 | 处理 |
|
||||
|---|---|---|---|
|
||||
| X1 | 16 个 orphan 脚本无任何文档引用(yida×13、finance×2、aiapp×1);`dws-shared/references/routing.md` 把「宜搭」路由到 dingtalk-misc,但 misc 产品索引无 yida 行、无 yida reference | `skills/multi/dingtalk-misc/scripts/`;`dingtalk-misc/SKILL.md:20-32` | 补文档(misc 产品索引 + reference)或移出发布包(→ C5) |
|
||||
| X2 | 死链:`dingtalk-chat/SKILL.md:131` 引用 `scripts/extract_media_id.py`,文件不存在(mono 也无) | 同上 | 补脚本或删引用(→ C4) |
|
||||
| X3 | 死链:`dws-shared/references/routing.md:22` 指向 `dingtalk-misc/references/markdown.md`,实际在 `dingtalk-markdown` | 同上 | 改指 `../../dingtalk-markdown/SKILL.md`(→ C4) |
|
||||
| X4 | `dingtalk-event/SKILL.md` 缺 `dws-shared` PREREQUISITE 与 `metadata:` 块(其余 19 个 skill 均有) | `skills/multi/dingtalk-event/SKILL.md:1-4` | 补齐(→ C4) |
|
||||
| X5 | 4 个 skill 仍是 🧪 EXPERIMENTAL + 「生产优先 mono」文案,与本工作区已翻转的默认矛盾(dingtalk-skill 已改,这 4 个漏改) | `dingtalk-profile/SKILL.md:15`、`dingtalk-hrbrain:15`、`dingtalk-markdown:15`、`dingtalk-pat:15` | 统一下调文案(→ C4,即 plan P2-4 剩余量) |
|
||||
| X6 | `<!-- SAFETY_PREAMBLE_INJECT -->` 标记存在于 5 个 SKILL.md,但仓库内无注入器 | `dingtalk-{pat,hrbrain,markdown,skill,profile}/SKILL.md` | 明确注入方(仓外流程则写注释)或移除标记(→ C4) |
|
||||
|
||||
### 2.5 测试与政策门覆盖
|
||||
|
||||
| 门面 | 覆盖 | 缺口 |
|
||||
|---|---|---|
|
||||
| `test/skill_tests.md` 覆盖表(40-54 行) | 13 产品 ≈256 用例 | 12/13 行引用 **mono 路径**(`references/products/...`),仅 dev 指 multi;`workbench` 行指向不存在的 `workbench.md`(54 行,mono/multi 均无);`devdoc` 行指 `simple.md`(47 行,multi 无此文件);未覆盖 doc/drive/mail/minutes/oa/sheet/wiki/aisearch/hrbrain/markdown/pat/profile/skill。multi 默认后需按 multi 路径重写并补产品(→ C6) |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | 锁 `dingtalk-chat/SKILL.md` ≤14000B + shortcut 区块不膨胀 + mono SKILL.md 不含「充分阅读产品参考文件」回归(9-38 行);`gen_skill_shortcut_sections.py --check` 同时写 mono 与 multi 的 shortcut 区块 | mono 下线判据(plan §5-4)要求先替代对 `skills/mono/SKILL.md` 的依赖 |
|
||||
| `make skill-command-integrity`(`check-skill-commands.sh` → `test/skill_static/skill_static_test.go:119-133`) | 静态校验 mono+multi 两树文档中的命令与 flag | 覆盖 OK;X2/X3 类 reference 死链不在其校验面 |
|
||||
|
||||
**Part 2 结论**:multi **没有**覆盖 mono 的全部能力 —— 产品参考层面是超集,
|
||||
但全局能力缺 M1–M3 三块(recovery / 确认门禁 / Schema 教学),加 M4–M6 三个
|
||||
小项;另有 X1–X6 六项 multi 内部不一致。补齐全部落在 `dws-shared`(M1/M2/M3)、
|
||||
`dingtalk-misc`(M4/M6/X1)、各产品 skill(X2/X4/X5)与打包流程(M5)。
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — 优先级排序实施清单
|
||||
|
||||
> 估时以 1 名熟悉本仓库的工程师计(人日),口径同 plan §8。
|
||||
> 「状态」列:✅ 本工作区已完成|🚧 部分完成|⬜ 未做。
|
||||
|
||||
### 3.1 P0 — 先堵会丢数据/双份派发的洞(≈6.5–8.5d)
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P0c-1 | ⬜ | agent home 清单下沉共享包,补 opencode 不对称 | 新 `internal/skillhome`(或 `internal/upgrade` 导出):合并 `paths.go:35-52` `knownSkillDirs` + `skill_setup.go:20-37` `skillSetupAgentHomes` + `skill_command.go:119-141` `agentSkillPaths`;setup/upgrade/测试共用 | 0.5d | — |
|
||||
| P0c-2 | ⬜ | 清单同步门禁 | 新 `scripts/policy/check-agent-homes-sync.sh`(进 `make policy`);install.sh:ps1:install.js:install-skills.sh 清单改可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | ❌ CANCELLED | state.json schema + 写入 | 2026-08-05:无运行时切换,不写 state.json | — | — |
|
||||
| P0b-2 | ❌ CANCELLED | 磁盘形态反推作 state 兜底 | 无 sticky / 无 state;upgrade 按包刷 multi | — | — |
|
||||
| P0-3 | ❌ CANCELLED | 备份式安装 + 失败整体回滚 | 2026-08-05:随切换产品线取消 | — | — |
|
||||
| P0a-1 | ✅ | upgrade 包驱动 multi | `UpgradeSkillLocations`:有 multi→始终 multi(含 mono 盘一次性迁移);legacy 无 multi→mono;不读 state | — | — |
|
||||
| P0a-2 | ✅ | force-multi 集成 / E2E | `paths_multi_test.go`(`MonoDiskMigratesToMulti` 等)+ `upgrade_skill_multi_e2e_test.go` | — | — |
|
||||
| P0-4 | ❌ CANCELLED | `x-dws-skill-mode` 请求头 | owner 决策移除 | — | — |
|
||||
|
||||
### 3.2 P1 — 生命周期命令 + 内容补全(≈7–7.5d,两条线可并行)
|
||||
|
||||
**管理命令线**
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P1-1 | ❌ CANCELLED | `dws skill mode` status/set/rollback/--dry-run | 2026-08-05:无运行时模式切换产品 | — | — |
|
||||
| P1-3 | ⬜ | `dws skill remove`(新增,缺口 #5) | 按磁盘/约定前缀精确删内置 skill(**不**依赖已取消的 state.json);`--dry-run` 预览 | 1d | — |
|
||||
|
||||
**内容补全线**(对应 Part 2 编号)
|
||||
|
||||
| ID | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| C1 | recovery 闭环进 multi(M1) | 新 `skills/multi/dws-shared/references/recovery-guide.md`;`dws-shared/SKILL.md:83-89` 错误最短路径加 recovery 步;`dws-shared/references/global-reference.md` 补 Recovery 节;`dingtalk-dev/SKILL.md:124` 断链改指 | 0.5d | — |
|
||||
| C2 | 确认门禁协议 + 危险操作索引(M2) | `dws-shared/SKILL.md` 增「确认门禁」节(移植 mono `SKILL.md:170-187` 协议)+ 危险操作索引表指向各产品 SKILL.md | 0.5d | — |
|
||||
| C3 | Schema 渐进查询教学(M3) | `dws-shared/SKILL.md` 渐进加载表加一行 + 新 `references/schema-usage.md`(改写 mono `SKILL.md:198-292`) | 0.5d | — |
|
||||
| C4 | multi 一致性修复(X2/X3/X4/X5/X6 = plan P2-4 剩余量) | `dws-shared/references/routing.md:22` 改指 dingtalk-markdown;`dingtalk-chat/SKILL.md:131` 死链处置;`dingtalk-event/SKILL.md` 补 PREREQUISITE+metadata;`dingtalk-{profile,hrbrain,markdown,pat}/SKILL.md:15` EXPERIMENTAL 文案下调;SAFETY_PREAMBLE_INJECT 标记处置 | 0.5d | — |
|
||||
| C5 | orphan 脚本与缺失产品处置(X1/M4/M6) | `dingtalk-misc/SKILL.md:20-32` 产品索引补 yida/finance/aiapp 行 + 新 reference(或把 16 个脚本移出发布包);`report_inbox_today.py` 验证后迁入或删除;aiapp 路由决策落 `dws-shared/references/routing.md` | 0.5–1d | — |
|
||||
| C6 | skill_tests.md multi 化 + 扩产品 | `test/skill_tests.md:40-54` 覆盖表改 multi 路径;修 workbench(54 行)/devdoc(47 行)死链;补 doc/drive/mail/minutes/oa/sheet/wiki 用例 | 1d | C1–C5(路径稳定后) |
|
||||
| C7 | LICENSE/NOTICE 进 multi(M5) | 20 个 `skills/multi/*/LICENSE|NOTICE`(或 `scripts/release/post-goreleaser.sh` 打包期注入) | 0.25d | — |
|
||||
|
||||
### 3.3 P2 / W4 — 收尾(≈1d)
|
||||
|
||||
| ID | 状态 | 任务 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P1-2 | ⬜ | beta 轨默认切 multi(版本门控;本工作区已全量切,可选择保留全量或回退为 beta 先行) | 0.5–1d | P1-1 |
|
||||
| P2-1 ~ P2-3 | ✅ | setup / install.sh / install.ps1 / install-skills.sh / install.js 默认 multi | — | 已完成 |
|
||||
| P2-4 | 🚧 | 文案翻转:README×2、dingtalk-skill 已改 ✅;4 个 EXPERIMENTAL 漏改 → 并入 C4 | — | — |
|
||||
| W4 | ⬜ | mono deprecation 警告(不删代码)+ mono 下线判据第 4 条(替代 `check-skill-context-budget.sh:10,34-38` 对 mono 的依赖) | 0.5d | P2、C6 |
|
||||
|
||||
### 3.4 依赖图与排期建议
|
||||
|
||||
```text
|
||||
P0c-1 ─► P0c-2
|
||||
P0b-1 ─► P0b-2 ─┐
|
||||
P0-3 ───────────┼─► P0a-1 ─► P0a-2 ─► P1-1 ─► P1-3
|
||||
P0b-1 ──────────┴─► P0-4
|
||||
C1…C5(互相独立,可与 P0 并行)─► C6
|
||||
```
|
||||
|
||||
- **W1**:P0c-1 → P0b-1/P0b-2 → P0-3 → P0a-1/P0a-2(管理面止血);并行 C1–C4(内容高危项)。
|
||||
- **W2**:P0-4、P1-1、P1-3;并行 C5、C7。
|
||||
- **W3**:C6(内容面收口)+ P1-2 beta 决策;`make policy` 全绿。
|
||||
- **W4**:mono deprecation + 观察(plan §4 原节奏不变)。
|
||||
|
||||
合计:P0 ≈ 6.5–8.5d + P1 管理 ≈ 3–3.5d + 内容 C1–C7 ≈ 3.5–4d + P2/W4 ≈ 1d
|
||||
= **14–17 人日**。砍法同 plan §8.4:内容线可砍 C3/C7(教学与法律文件可后置),
|
||||
管理线不可砍 P0b/P0-3/P0a(缺了就是现在这副「能装不能管」的样子)。
|
||||
@@ -0,0 +1,146 @@
|
||||
# Skill 分发与消费机制调研(含 lark-cli 对标)
|
||||
|
||||
> 配套方案:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> 调研日期:2026-08-04,基于 `feat/skill-mode-migration` 工作区代码级梳理 +
|
||||
> lark-cli(larksuite/cli@main)公开仓库调研。
|
||||
|
||||
## 1. DWS 分发链路(源树 → 制品 → 渠道)
|
||||
|
||||
```
|
||||
skills/mono/ ─┬─ go build ──► embed.FS(skills_embed.go:28)
|
||||
skills/multi/ ┘ │ dws skill setup 默认源
|
||||
│
|
||||
└─ post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
布局:zip 根 = mono 副本(向后兼容)+ mono/ + multi/
|
||||
│
|
||||
┌───────┬───────┼────────┬─────────┬──────────┐
|
||||
GitHub Gitee OSS npm Homebrew 专项脚本
|
||||
Release (镜像) (只发 tarball (cellar install-event/
|
||||
│ │ 不读) │ 不铺agent) install-devapp
|
||||
│ │ │
|
||||
install.sh/ps1 postinstall
|
||||
install-skills.sh install.js
|
||||
│ │
|
||||
├─► 各 agent home ◄────┤ 永远 mono:<agent>/dws/
|
||||
└─► ~/.dws/skills/{mono,multi} 缓存
|
||||
```
|
||||
|
||||
渠道清单(7 个安装/分发面):
|
||||
|
||||
| 面 | skill 行为 | mode 概念 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | 装 mono 到 agent homes + 双缓存;选 multi **只打印提示、连缓存都跳过** | 有(半残) |
|
||||
| `scripts/install.ps1` | 同 install.sh(Windows) | 有(半残) |
|
||||
| `scripts/install-skills.sh` | 只装 mono,缓存 mono+multi | 无 |
|
||||
| `build/npm/install.js` | 永远 mono;缓存 mono+multi | 无 |
|
||||
| Homebrew formula | 整包 zip 进 cellar,不铺 agent、不写缓存,caveats 提示手动 setup | 无 |
|
||||
| `scripts/install-event.sh` | 装 mono + 缓存 `multi/dingtalk-event`;多 `.config/opencode/skills` | 无 |
|
||||
| `scripts/install-devapp.sh` | 缓存 `multi/dingtalk-dev` | 无 |
|
||||
|
||||
产物事实:`dws-skills.zip` 已含 mono/multi 双树,**切 multi 不需要改 release 产物**。
|
||||
|
||||
## 2. DWS 消费链路(源 → agent 目录)
|
||||
|
||||
### 2.1 `dws skill setup`(`internal/app/skill_setup.go`)
|
||||
|
||||
- 源优先级:`--source` / `DWS_SKILL_SOURCE`(失败不回退)→ **embed 默认**
|
||||
(`skill_setup_embed.go:50-58`);legacy 候选(exe 旁/cwd/`~/.dws/skills`)
|
||||
仅在绕过 wrapper 直连时才走。
|
||||
- 目标:`skillSetupAgentHomes` 16 个 agent home,父目录门控(i=0 `.agents`
|
||||
无条件,其余需 `~/.claude` 这类父目录存在)。`--target all` **不含
|
||||
opencode**,但 `agentSkillPaths` 命名 target 含(不对称,测试只锁单向)。
|
||||
- 布局:mono → `<agent-home>/dws/`;multi → `<agent-home>/` 平铺兄弟目录。
|
||||
- 互斥清理:装 mono 删 `dingtalk-*`、装 multi 删 `dws/`;**best-effort,
|
||||
失败仅 warning 继续装**(`cleanupMutualExclusion:611-620`),无备份。
|
||||
- multi 过滤:`-s/--skill` 与 `-x/--exclude` 互斥;`dws-shared` 强制包含;
|
||||
未点名的已有 `dingtalk-*` 保留(additive)。
|
||||
|
||||
### 2.2 `dws upgrade`(`internal/upgrade/paths.go`)
|
||||
|
||||
- `LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations` 只写
|
||||
`<agent>/dws/`:**不识 multi、不清理 `dingtalk-*`、不更新 `~/.dws/skills`**。
|
||||
- 后果:multi 用户升级后 **mono + multi 共存**,Agent 双份派发。
|
||||
- `--rollback` 只回滚二进制,不回滚 skill。
|
||||
|
||||
### 2.3 市场 skill(`dws skill install`)
|
||||
|
||||
解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念;互斥清理按
|
||||
`dingtalk-*` 前缀扫描,依赖"市场无同名前缀"隐性约定。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **无任何已安装模式状态**:`~/.dws/` 有 auth/backups/cache,无 install
|
||||
manifest;判断 mode 只能看磁盘形态。
|
||||
- `~/.dws/skills` 缓存事实**只写不读**(默认 setup 走 embed),upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
|
||||
## 3. lark-cli 分发机制(larksuite/cli@main)
|
||||
|
||||
npm 包 `@larksuite/cli` 是薄壳(`files` 仅 install.js / install-wizard.js /
|
||||
run.js / checksums.txt):
|
||||
|
||||
- **二进制**:postinstall 按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发);镜像链 GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底;host allowlist + checksum 双保险;`run.js` 缺二进制自动补下,
|
||||
Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:不进 npm 包、不进二进制。repo 根 `skills/` 即事实源,由生态
|
||||
安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)安装;
|
||||
wizard 首选 `https://open.feishu.cn` 直链,GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install` →
|
||||
install-wizard.js 串联 4 步(npm 全局装/升级 → skills → config init →
|
||||
auth login),每步幂等(`skills ls -g` 检测 `lark-*` 已装则跳过);
|
||||
非 TTY 降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 提供的能力:
|
||||
|
||||
- 76 个 agent 目录清单生态维护,自动探测;project/global 双 scope;
|
||||
- **symlink canonical(推荐)或 copy**;symlink 下升级 = 更新一处;
|
||||
- `list / find / update / remove` 全生命周期;**skill 升级由
|
||||
`npx skills update` 承担,lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
## 4. 对标:DWS vs lark-cli
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/`(源码即事实源) | zip + embed + 5 处拷贝缓存 |
|
||||
| 安装器 | 1 个(生态工具) | 7 个自维护脚本,已漂移 |
|
||||
| 落盘 | symlink 单点更新 | 全量 copy,升级重写 16 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,不识 multi |
|
||||
| mono/multi | 不存在此问题(天生多 skill) | 互斥/切换/状态全自建 |
|
||||
| npm 包 | 薄壳运行时下载 | 全平台 archive + zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS 只发不读 |
|
||||
|
||||
## 5. "分发外包给生态,自己只维护源码目录"是什么
|
||||
|
||||
职责切分:skill 的分发/安装/升级/卸载交给生态标准化工具(`npx skills`,
|
||||
"agent skill 界的 npm"),DWS 只保证 repo 里 `skills/` 符合 agentskills.io
|
||||
规范。它消掉的正是 DWS 现在自维护的四块问题:
|
||||
|
||||
1. **N 份事实源**(zip 三拷贝 + embed + 缓存 + 16 home)→ 只剩 repo 目录一份;
|
||||
2. **7 个自写安装器** → 零个,agent 清单别人维护(新 agent 自动支持);
|
||||
3. **自建升级/切换/回滚**(UpgradeSkillLocations、互斥清理、方案中的
|
||||
state.json/备份/`dws skill mode`)→ symlink 模式更新 canonical 一处;
|
||||
4. **lark-cli 因此根本没有 mono/multi 之争**,也没有 P0 要修的那些 bug。
|
||||
|
||||
### 代价与前提(不是免费午餐)
|
||||
|
||||
| 风险 | 说明 | 对策 |
|
||||
|---|---|---|
|
||||
| 版本错配 | DWS skill 从 Cobra 树生成,与 CLI 版本强耦合;embed 天然同版,生态安装从主分支拉可能错配。**已实测**:`skills add`(v1.5.21)无 tag/ref 版本固定参数,`@` 后接的是 skill 名而非 git ref;唯一的可复现机制是 project 级 `skills-lock.json`(experimental_install) | 短期:发布说明引导"skill 随 CLI 升级(`skills update`)";中期:release 时推一个 `release/vX.Y.Z` 镜像分支或专用 skills 镜像仓供按版本安装;或接受错配(skill 内容为文档,错配成本=提到不存在的命令) |
|
||||
| 网络可达 | 依赖 npx + GitHub;大陆/内网现靠 Gitee fallback | `skills` CLI 支持任意 git URL,Gitee 镜像仓兜底,链路需验证 |
|
||||
| 离线/打包场景 | 悟空 bundled-skills、企业预装镜像生态通道覆盖不了(2026-08-05 注:悟空分发线已于当日下线,「悟空 bundled-skills」一项不再适用;企业预装镜像约束仍在) | 自维护打包保留一条 |
|
||||
| 生态工具策略漂移 | 清单/发现约定/默认值被动跟随 | 作为增量通道而非唯一通道,保留 embed 兜底 |
|
||||
|
||||
## 6. 结论
|
||||
|
||||
- **生态分发通道已否决**(2026-08-04):无版本固定(`skills add` 不支持
|
||||
tag/ref,实测 v1.5.21)、依赖 Node + GitHub 可达、mono 会被一起发现、
|
||||
悟空/离线场景覆盖不了。分发维持全自维护。(2026-08-05 注:悟空分发线
|
||||
已于当日下线,「悟空场景覆盖不了」一条随之失效;其余否决理由与结论
|
||||
不变。)
|
||||
- 一个月内:按方案 P0–P2 修自维护通道并切 multi 默认(zip + embed +
|
||||
安装脚本,产物布局不变)。
|
||||
- 终态即"自维护通道修好之后"的形态,不再向 lark-cli 的生态外包形态收敛。
|
||||
- 调研保留备查:`npx -y skills add . --list` 实测可发现全部 21 个 skill
|
||||
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
|
||||
@@ -0,0 +1,307 @@
|
||||
# Skill 多 skill(multi)切换方案
|
||||
|
||||
> 目标:一个月内把 DWS 的 Agent skill 默认安装形态从 mono(单 skill)切换为
|
||||
> multi(按产品拆分),两套并行期后下掉 mono。
|
||||
> 本文基于对分发/消费链路的代码级梳理(2026-08-04),所有锚点均可跳转验证。
|
||||
> 机制调研与 lark-cli 对标细节:[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 终态 | 新装/升级默认铺 multi(`<agent-home>/dingtalk-*/`、`dws-shared/`);mono 仅 opt-in;最终物理删除 mono |
|
||||
| 并行期 | 约一个月,mono 保留可切换、可回退 |
|
||||
| 硬约束 | DWS 无服务端灰度/远程配置;skill 是本地文件分发;安装面至少 7 个且已有漂移 |
|
||||
| 前置原则 | 先修已存在的 upgrade×multi 双份 bug,再谈切默认 |
|
||||
|
||||
## 2. 现状关键事实(梳理结论)
|
||||
|
||||
分发侧:
|
||||
|
||||
- `dws-skills.zip` 布局:zip 根 = mono 副本(向后兼容)+ `mono/` + `multi/`
|
||||
(`scripts/release/post-goreleaser.sh:220-248`)。**产物无需改动**。
|
||||
- 二进制 `//go:embed all:skills/mono all:skills/multi`(`skills_embed.go:28`),
|
||||
`dws skill setup` 默认源就是 embed,**与分发渠道无关**。
|
||||
- 7 个安装/分发面:install.sh、install.ps1、install-skills.sh、npm install.js、
|
||||
Homebrew formula、install-event.sh、install-devapp.sh。
|
||||
- OSS 只发不读;大陆链路靠 Gitee fallback。
|
||||
|
||||
消费侧:
|
||||
|
||||
- `dws skill setup`:源优先级 `--source`/env → embed;目标 16 个 agent home
|
||||
(父目录门控);互斥清理 best-effort、失败仅 warning、无备份无回滚
|
||||
(`internal/app/skill_setup.go`)。
|
||||
- `dws upgrade`:`LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations`
|
||||
只写 `<agent>/dws/`、不清理 `dingtalk-*`、不更新 `~/.dws/skills`
|
||||
(`internal/upgrade/paths.go:119-172`)。**multi 用户升级后 mono+multi 共存**。
|
||||
- `dws upgrade --rollback` 只回滚二进制,不回滚 skill。
|
||||
- **无任何已安装模式状态**:`~/.dws/` 无 install manifest。
|
||||
- `~/.dws/skills` 缓存事实上只写不读(默认 setup 走 embed),且 upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
- `skill setup --target all` 不含 opencode,但 `agentSkillPaths` 含
|
||||
(`skill_command.go:119-141` vs `skill_setup.go:20-37`)。
|
||||
- 市场 skill(`dws skill install`)解压到 agent skills 根,与内置 skill 无 mode
|
||||
概念;互斥清理按 `dingtalk-*` 前缀扫描,依赖"市场无同名前缀"这一隐性约定。
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
### 3.1 单一事实源收敛(P0c)
|
||||
|
||||
agent home 清单目前在 5+ 处各写一份(注释约定 keep in sync,无门禁)。
|
||||
|
||||
- Go 侧:`skillSetupAgentHomes` 与 `knownSkillDirs` 合并为一个导出列表
|
||||
(放在 `internal/upgrade` 或新 `internal/skillhome` 包),补 opencode,
|
||||
setup/upgrade/测试共用。
|
||||
- 脚本侧:install.sh / install.ps1 / install.js / install-skills.sh /
|
||||
install-event.sh / install-devapp.sh 的清单由同一个 JSON 生成或政策脚本
|
||||
比对(新增 `scripts/policy/check-agent-homes-sync.sh`,进 `make policy`)。
|
||||
|
||||
### 3.2 安装状态文件(P0b)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划新增 `~/.dws/skills/state.json`(mode / cli_version / installed /
|
||||
previous / backup)。**已取消**:owner 决策不做运行时模式切换产品;upgrade
|
||||
有 multi 包时直接刷 multi,不再需要状态文件作为正确性或切换前提。
|
||||
|
||||
### 3.3 备份式安装与真回滚(P0a)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划把 `RemoveAll` 改为 `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/` 并
|
||||
支持失败整体回滚 / `dws skill mode rollback`。**已取消**:无运行时切换则
|
||||
不交付备份回滚产品面。安装/清理仍可为直接删除;mono retirement 版本若做
|
||||
一次性迁移,届时再单独评估临时备份,不预建 switch UX。
|
||||
|
||||
### 3.4 upgrade 包驱动 multi 刷新(P0a,已落地)
|
||||
|
||||
> 取代「按 state.json mode 刷新」与「磁盘粘性」:有 multi 包时一次性刷成 multi。
|
||||
|
||||
- `UpgradeSkillLocations(extractedDir)`:**不**推断磁盘布局。
|
||||
- 包内有 multi 技能树 → 始终 multi 刷新(清 `dws/`、刷产品 skill、刷
|
||||
`~/.dws/skills/multi`);存量 mono 在日常 upgrade 上一并迁走。
|
||||
- 无 multi 树的 legacy 包 → mono 刷新回退。
|
||||
- 这是包驱动的一次性迁移,**不是**运行时 mode-switch 产品。
|
||||
- 互斥清理在 multi 路径内执行,避免双布局长期共存。
|
||||
|
||||
### 3.5 模式切换命令(P1)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划 `dws skill mode status|set|rollback|--dry-run`。**已取消**。
|
||||
|
||||
用户若需改布局:重新走安装入口(`dws skill setup --mode <mono|multi> --yes`
|
||||
或安装脚本 `DWS_SKILL_MODE=`),不是 lifecycle 切换命令。
|
||||
|
||||
### 3.6 默认切换(P2)
|
||||
|
||||
改默认值="multi 默认、mono opt-in",七个面一起改 + 门禁锁一致性:
|
||||
|
||||
| 面 | 改动 |
|
||||
|---|---|
|
||||
| `dws skill setup` | 无 `--mode` 时默认 multi(交互选项顺序反转,mono 标 legacy) |
|
||||
| install.sh / install.ps1 | multi 分支**真正安装**(现在是打印提示跳过);`DWS_SKILL_MODE=mono` opt-in;TTY 默认项改 multi |
|
||||
| install-skills.sh / npm install.js | 从零加 mode 支持(env `DWS_SKILL_MODE` / `--skill-mode`),默认 multi |
|
||||
| Homebrew formula | caveats 改提示 `dws skill setup`(默认即 multi),无需改资源 |
|
||||
| install-event.sh / install-devapp.sh | 维持单 skill 语义,但改走共享 install 函数 |
|
||||
|
||||
文档同步:README/README_zh(`README_zh.md:81` "默认 1")、
|
||||
`skills/multi/dingtalk-skill/SKILL.md` 的 🧪 EXPERIMENTAL 措辞下调、
|
||||
install.sh 内 multi 警告文案、AGENTS.md"生产优先 mono"表述。
|
||||
|
||||
### 3.7 灰度与止血(无服务端能力下的替代)
|
||||
|
||||
- **L1 渠道灰度(先行)**:beta 轨(GitHub prerelease / npm `beta` dist-tag /
|
||||
`dws upgrade --beta`)先切默认 multi,stable 保持 mono。零新增代码。
|
||||
- **L2 确定性分桶(可选增强)**:随 release 发 `rollout.json`
|
||||
(GitHub asset + OSS 同步),安装/升级时 `hash(machine-id) % 100 < pct` 决策
|
||||
并粘入 `state.rollout`。规则:本地显式 env/flag 永远优先;拉取失败 fail-safe
|
||||
mono(并行期)/ 保持现状(切默认后);存量机器不被 rollout 改模式。
|
||||
- **Kill switch**:`rollout.json` pct=0(若上 L2,已砍)+ beta 撤回 +
|
||||
公告引导重装 `dws skill setup --mode mono --yes`(**无** `skill mode`
|
||||
命令;备份回滚产品已随 D5 取消)。
|
||||
- **可观测**:原 `x-dws-skill-mode` 请求头方案 **CANCELLED**(2026-08-05
|
||||
owner);灰度与下线判断改 issue 反馈 + 主动回访。
|
||||
|
||||
### 3.8 安装与升级的语义矩阵(两条路径都要支持 multi)
|
||||
|
||||
| 场景 | 时期 | 行为 |
|
||||
|---|---|---|
|
||||
| 新装 | 并行期(切默认前) | mono 默认,`DWS_SKILL_MODE=multi` / 交互可选 multi |
|
||||
| 新装 | 切默认后 | **multi 默认**,`DWS_SKILL_MODE=mono` / `--mode mono` / TTY opt-in(**仅安装时**) |
|
||||
| 升级(存量 mono) | 含 `multi/` 的包 | **一次性刷成 multi**(清 `dws/`);无 switch 提示命令 |
|
||||
| 升级(存量 multi) | 含 `multi/` 的包 | multi 刷新,清过期 skill,刷 multi 缓存 |
|
||||
| 升级(无安装) | 含 `multi/` 的包 | 安装 multi(与安装默认一致) |
|
||||
| 升级(任意磁盘) | legacy 无 multi 树的包 | mono 刷新回退 |
|
||||
| `dws upgrade --rollback` | 任意 | 只回滚二进制(现状);skill 无独立 rollback 命令 |
|
||||
|
||||
原则:**升级不做磁盘粘性**;有 multi 包时一律刷 multi(含存量 mono 一次性
|
||||
迁移)。默认翻转与安装 opt-in 仍只影响新装/重装。无运行时 `dws skill mode`
|
||||
产品。
|
||||
|
||||
## 4. 阶段与时间线(4 周)
|
||||
|
||||
| 周 | 内容 | 出口标准 |
|
||||
|---|---|---|
|
||||
| W1 | P0a upgrade mode-aware + P0b state.json + P0c 清单收敛与门禁 + 备份式安装 | upgrade×multi 集成测试(装 multi → upgrade → 无 `dws/` 残留);备份回滚测试(模拟中途失败);`make policy` 含 homes 同步检查 |
|
||||
| W2 | P1 `dws skill mode`(status/set/rollback/dry-run);beta 轨默认切 multi(L1);`x-dws-skill-mode` 头 | 双向切换 + 中断恢复手工验收;beta 轨冒烟 |
|
||||
| W3 | P2 stable 默认切 multi、mono 降为 opt-in;文案翻转;(可选 L2 分桶 5%→20%) | 七面默认行为一致(政策脚本);issue/请求头占比观察 |
|
||||
| W4 | (L2 则 50%→100%);mono 打 deprecation 警告,**不删代码** | 连续 7 天无 multi 相关 P1 |
|
||||
|
||||
## 5. mono 下线判据(不满足则不删)
|
||||
|
||||
> 判据更新(2026-08-05):原判据 3「悟空 bundled skill 分发线(dws_res →
|
||||
> bundled-skills,本仓库外)已切 multi」作废——悟空分发线已于当日决策
|
||||
> 下线(见 [skill-multi-roadmap.md](skill-multi-roadmap.md)
|
||||
> 「悟空线下线的影响(2026-08-05)」),无仓外 mono 依赖,mono 下线不再
|
||||
> 有仓外节奏闸门。判据重新编号如下(原 4/5 顺延为 3/4)。
|
||||
|
||||
1. `x-dws-skill-mode=multi` 请求占比 ≥ 90%;
|
||||
2. mono 主动 opt-in 率 ≤ 2%(install 脚本/命令埋点);
|
||||
3. 连续两周无 multi P1;
|
||||
4. 已有等价政策门替代 `scripts/policy/check-skill-context-budget.sh` 对
|
||||
`skills/mono/SKILL.md` 的依赖;`skills_embed.go` 去掉 `all:skills/mono`;
|
||||
存量 `<agent>/dws` 目录有"遇到即迁移清理"逻辑。
|
||||
|
||||
满足后单独一个版本窗口物理删除 `skills/mono/`,install/setup/upgrade 中 mono
|
||||
分支改为报错并指向 `dws skill mode set multi`。
|
||||
|
||||
## 6. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 半装状态(清理成功、拷贝失败) | 3.3 备份式安装,失败整体回滚 |
|
||||
| multi 用户被 upgrade 塞回 mono | 3.4 mode-aware,P0a 先修 |
|
||||
| Agent 目录清单继续漂移 | 3.1 单一事实源 + policy 门禁 |
|
||||
| 互斥清理误伤市场 `dingtalk-*` skill | 清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集,写成测试 |
|
||||
| 无灰度全切翻车 | beta 轨先行 + 备份回滚 + kill switch 公告命令 |
|
||||
| 用户不重启 AI 工具读到旧 skill | mode set / setup / install 输出统一提示重启 |
|
||||
| `~/.dws/skills` 缓存与 embed 版本漂移 | upgrade 时同步刷新对应 mode 缓存;长期可评估废弃缓存 |
|
||||
|
||||
## 7. 分发机制对标 lark-cli(2026-08-04 调研)
|
||||
|
||||
### 7.1 lark-cli 的实际分发结构
|
||||
|
||||
npm 包 `@larksuite/cli` 是一个**薄壳**(package.json `files` 只有
|
||||
`install.js` / `install-wizard.js` / `run.js` / `checksums.txt`):
|
||||
|
||||
- **二进制**:postinstall 时按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发布);镜像链 = GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底,host allowlist + checksum 双保险。`run.js` 在二进制缺失时
|
||||
自动补下载;Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:**不进 npm 包、不进二进制**。repo 根 `skills/` 目录即事实源,
|
||||
由生态通用安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)
|
||||
安装;wizard 首选 `https://open.feishu.cn` 直链、GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install`
|
||||
子命令 → install-wizard.js 串联 4 步(npm 全局装/升级 → skills 安装 →
|
||||
config init → auth login),每步幂等可跳过(`skills ls -g` 检测 `lark-*`
|
||||
已装则跳过);非 TTY 自动降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 的能力(lark-cli 免费获得的):
|
||||
|
||||
- **76 个 agent 的目录清单由生态维护**,自动探测已装 agent;project/global
|
||||
两种 scope;
|
||||
- **symlink 到 canonical 副本(推荐)或 copy** —— symlink 模式下升级
|
||||
= 更新 canonical 一份,所有 agent 即时生效;
|
||||
- `list / find / update / remove` 全套生命周期命令,skill 升级由
|
||||
`npx skills update` 承担,**lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
### 7.2 与 DWS 的关键差异
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/` 目录(源码即事实源) | zip + 二进制 embed + 5 处拷贝缓存 |
|
||||
| 安装器数量 | **1 个**(生态工具,76 agent 清单别人维护) | **7 个**自维护脚本,清单已漂移 |
|
||||
| 落盘方式 | symlink(canonical 单点更新) | 全量 copy,升级要重写 16 个 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,且不识 multi |
|
||||
| mono/multi 问题 | **不存在** —— 天生按目录多 skill | 互斥清理/模式切换/状态全是自建 |
|
||||
| npm 包体积 | 薄壳(运行时下载单平台二进制) | 全平台 archive + skills.zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS(只发不读) |
|
||||
|
||||
### 7.3 结论:生态分发通道**已否决**(2026-08-04)
|
||||
|
||||
~~借生态安装器做分发通道~~ 方向经评估后**放弃**。否决原因(均为实测):
|
||||
|
||||
1. **无版本固定**:`skills add`(v1.5.21)不支持 tag/ref 安装,而 DWS skill
|
||||
从 Cobra 树生成、与 CLI 版本强耦合,错配不可接受;唯一可复现机制
|
||||
`skills-lock.json` 仍是 experimental。
|
||||
2. **依赖 Node + GitHub 可达**:curl|sh / Homebrew / 大陆 Gitee fallback 的
|
||||
用户环境大量无 Node,生态通道在这些场景是断的。
|
||||
3. **mono 会被一起发现**:发布即制造双份,须等 mono 下线才能发布,节奏不合。
|
||||
4. **悟空 bundled / 离线预装**生态通道永远覆盖不了。(2026-08-05 注:
|
||||
悟空分发线已下线,此条约束随之消失;前 3 条否决理由仍成立。)
|
||||
|
||||
**决策:分发维持全自维护通道**(zip + embed + 安装脚本),按 P0–P2 落地;
|
||||
不从 lark-cli 借鉴分发架构。可保留的借鉴点只剩两个本地语义,与生态无关:
|
||||
|
||||
1. **wizard 式幂等安装**:`install.sh` 的 multi 分支从"打印提示跳过"改为
|
||||
检测 state 可重入的真正安装(参考 install-wizard 的步骤化幂等)。
|
||||
2. **npm 下载校验**:install.js 如后续薄壳化,可参考其 host allowlist +
|
||||
checksum 双保险与镜像链(GitHub → registry 派生 → npmmirror)设计。
|
||||
与本次 multi 迁移解耦,不单列任务。
|
||||
|
||||
## 8. 工作量评估与任务拆解
|
||||
|
||||
> 以 1 名熟悉本仓库的工程师计(人日)。规模基线:涉及生产代码约 5.0k 行
|
||||
> (Go 3.0k + shell/ps1/js 2.0k),已有测试 1.2k 行可复用。
|
||||
|
||||
### 8.1 总体判断
|
||||
|
||||
**核心路径 13–17 人日,一人一个月可行但偏紧**。风险不在 Go 而在"shell /
|
||||
ps1 / js 三语言 × 七面同步"和对应的测试矩阵。可选项(rollout 分桶、npm
|
||||
薄壳化、symlink、生态通道发布)全部可砍可后置,砍后**最小可行集 8–10
|
||||
人日**(见 8.4)。
|
||||
|
||||
### 8.2 任务拆解(带依赖)
|
||||
|
||||
```
|
||||
P0c 清单收敛 ──┐
|
||||
P0b state.json ─┼─► P0a upgrade mode-aware ─► P1 skill mode ─► P2 切默认
|
||||
备份式安装 ────┘ │ │
|
||||
└─► x-dws-skill-mode 头 └─► beta 轨先切(P2 预演)
|
||||
```
|
||||
|
||||
| # | 任务 | 改动面 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P0c-1 | agent home 清单下沉共享包(setup/upgrade 共用),补 opencode | `internal/upgrade/paths.go` 或新 `internal/skillhome`(~80 行新代码) | 0.5d | — |
|
||||
| P0c-2 | `scripts/policy/check-agent-homes-sync.sh`:比对 sh/ps1/js/sh 专项清单与 Go 清单,进 `make policy` | 新脚本 ~120 行 + 各脚本清单改成可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | state.json schema + setup 写入(含 installed 列表、agent_homes、previous) | `internal/app/skill_setup.go` +新文件 ~200 行 | 1d | — |
|
||||
| P0b-2 | 磁盘形态反推(dws/ → mono;dingtalk-* → multi;都有 → drift 报错)+ 单测 | ~120 行 | 0.5–1d | P0b-1 schema |
|
||||
| P0-3 | 备份式安装:RemoveAll→mv backup、失败自动回滚、保留最近 2 份、改 warning 语义 | setup install 两函数重写 ~150 行 + 测试 | 1.5–2d | — |
|
||||
| P0a-1 | `UpgradeSkillLocations(dir, mode)`:mono 现状+清残留;multi 从 zip `multi/` 平铺刷新+清 `dws/`+刷缓存 | `internal/upgrade/paths.go` ~150 行 | 1.5d | P0c-1、P0b |
|
||||
| P0a-2 | upgrade×multi 集成测试(装 multi→upgrade→无 dws/ 残留、dingtalk-* 已刷新) | 测试 ~200 行 | 1d | P0a-1 |
|
||||
| P0-4 | `x-dws-skill-mode` 请求头(仿 `x-dws-channel`) | `internal/auth/oauth_helpers.go` 附近 ~30 行 | 0.5d | P0b |
|
||||
| P1-1 | `dws skill mode` status/set/rollback/--dry-run | 新文件 ~350 行 + 测试 | 2–2.5d | P0b、P0-3 |
|
||||
| P1-2 | beta 轨默认切 multi(版本门控的默认值翻转,stable 不变) | setup/install.sh 默认值逻辑 ~40 行 | 0.5–1d | P1-1 |
|
||||
| P2-1 | setup 默认翻转 + 交互选项反转 + mono 标 legacy | ~30 行 + 测试更新 | 0.5d | P1 |
|
||||
| P2-2 | install.sh / install.ps1 的 multi 分支**真装**(幂等、读 state 跳过) | 两个脚本各 ~80 行 | 1.5d | P0b(脚本侧写 state) |
|
||||
| P2-3 | install.js / install-skills.sh 加 mode 支持(env + flag,默认 multi) | 各 ~60 行 | 1d | P2-2 同批 |
|
||||
| P2-4 | 文案翻转:README×2、SKILL.md EXPERIMENTAL 下调、install 脚本提示、AGENTS.md | 纯文档 | 0.5d | — |
|
||||
| W4 | mono deprecation 警告(不删代码)+ 观察 | ~20 行 | 0.5d | P2 |
|
||||
|
||||
**小计:核心 13–17 人日**(P0 ≈ 6.5–8.5,P1 ≈ 3–3.5,P2 ≈ 3.5,W4 0.5)。
|
||||
|
||||
可选/后置:rollout.json 分桶 2–3d;`setup --link` symlink 1–2d(需逐 agent
|
||||
验证);npm 薄壳化 2–3d(独立立项)。
|
||||
|
||||
### 8.3 风险最高的两处(先动)
|
||||
|
||||
1. **P0a upgrade multi 刷新语义**:additive 安装 vs upgrade 全量刷新之间存在
|
||||
一个真实设计题 —— 用户手动 `-x` 排除过的 skill,upgrade 要不要装回来?
|
||||
答案:以 state.json 的 `installed` 为准做增量刷新,未装的不得补装
|
||||
(否则违背 additive 语义)。这条必须在 P0a 开工前定死。
|
||||
2. **P0-3 备份回滚改语义**:现有测试断言"清理失败继续装",改语义会动
|
||||
`skill_setup_full_coverage_test.go` 多处;回滚恢复顺序(先恢复再报错)
|
||||
要用故障注入测试覆盖。
|
||||
|
||||
### 8.4 一个月做不完时的砍法
|
||||
|
||||
按价值/成本比从后往前砍:rollout 分桶(L1 beta 轨已够)→ install.js /
|
||||
install-skills.sh mode 支持(npm 渠道用户量小,可先只改 sh/ps1)。砍后**最小可行集 8–10 人日**:
|
||||
|
||||
> P0c-1 + P0b + P0-3 + P0a + P1-1 + P2-1 + P2-2(仅 sh/ps1)+ 文案
|
||||
|
||||
即:upgrade 不再制造双份、有状态可回滚、setup 与主流安装脚本默认 multi。
|
||||
npm/install-skills.sh 维持 mono 显式行为并在输出中标注即将切换。
|
||||
|
||||
## 9. 明确不做
|
||||
|
||||
- 不做服务端远程配置/灰度平台(用 beta 轨 + rollout.json 替代)。
|
||||
- 不动 `dws-skills.zip` 产物布局(已含 mono/multi 双树)。
|
||||
- 不动市场 skill(`dws skill install`)的安装语义。
|
||||
- 并行期内不删 mono 代码与产物,只降级为 opt-in。
|
||||
@@ -0,0 +1,153 @@
|
||||
# Skill multi 迁移:技术方案(as-implemented)与 Roadmap
|
||||
|
||||
> 本文是 multi 迁移的当前事实源:第一部分记录**已落地实现**(代码级锚点,
|
||||
> 均可跳转验证),第二部分是带实时状态的 roadmap。
|
||||
> 原始方案 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) 的
|
||||
> 若干"待做"描述已被后续决策取代(见决策记录 D1/D5);分发机制调研结论见
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
> 代码快照:`feat/skill-mode-migration` @ `402429ac` + 工作区
|
||||
> upgrade-force-multi 调整(2026-08-05)。
|
||||
|
||||
## 状态速览
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 当前阶段 | **阶段 1 / 1.5 ✅**(安装/升级默认 multi 已落地);**阶段 2 运行时切换产品线 ❌ CANCELLED**(2026-08-05 owner 决策) |
|
||||
| 硬 deadline | **2026-08-30**:安装/升级默认 multi(✅)+ upgrade **有 multi 包时一次性刷成 multi**(不做磁盘粘性)+ mono 仅安装时 opt-in;mono **物理下线**仍可在独立 retirement 版本推进,但**不再**依赖 `dws skill mode` / 备份回滚产品 |
|
||||
| 已完成 | 五面默认 multi、`dws skill setup` 默认 multi、互斥清理对称、文案翻转;upgrade 有 `multi/` 时一律刷新 multi(存量 mono 一次性迁移) |
|
||||
| 下一步 | beta/L1 版本门控与观察(靠 issue/回访,**无** `x-dws-skill-mode` 埋点);可选 agent-home 清单门禁;内容 C 线并行 |
|
||||
| 终态 | mono 仅安装时 opt-in;日常 upgrade(含 multi 的包)刷 multi;mono 物理删除仍可在 dedicated retirement 版本收尾(非用户切换命令) |
|
||||
|
||||
### 简化设计(2026-08-05 起生效)
|
||||
|
||||
1. **安装时一次决定**:默认 multi;`DWS_SKILL_MODE=mono` / `--mode mono` / 安装器 TTY 选 mono 为唯一 opt-in。
|
||||
2. **装完无运行时切换产品**:不做 `dws skill mode set/rollback`,不做备份式安装 / `state.json` 记账产品面。
|
||||
3. **升级不做粘性**:release zip 含 `multi/` 时 **一律** 刷新 multi(清 `dws/`、刷产品 skill + 缓存);仅 legacy 无 multi 树的包回退 mono 路径。存量 mono 在日常 upgrade 上一次性迁到 multi。
|
||||
4. **mono 下线**:安装侧仍可 opt-in;upgrade 已承担「有 multi 包即迁走」;物理删 mono 树可另议 retirement。
|
||||
5. **可观测**:`x-dws-skill-mode` 请求头已按 owner 决策移除;灰度靠 issue + 回访。
|
||||
|
||||
---
|
||||
|
||||
# 第一部分:技术方案(as-implemented)
|
||||
|
||||
## 1. 升级:包驱动 multi 刷新(`dws upgrade`)
|
||||
|
||||
核心语义:**升级不做磁盘粘性**;产物有 `multi/` 时一次性刷成 multi。
|
||||
无需 `state.json`,也无运行时切换命令。
|
||||
|
||||
- `LocateSkillsRoot` 优先返回 zip 内 `multi/`(`internal/upgrade/paths.go`)。
|
||||
- `UpgradeSkillLocations`:包内有 multi 技能树 → **始终** `upgradeMultiSkillLocations`
|
||||
(平铺 `dingtalk-*` + `dws-shared`,删 mono 残留 `dws/`,清过期 multi skill,
|
||||
刷 `~/.dws/skills/multi`);无 multi 树的 legacy 包才走 mono 刷新。
|
||||
- 这是 upgrade 上的一次性迁移,不是 `dws skill mode` 产品。
|
||||
|
||||
测试:`internal/upgrade/paths_multi_test.go`(含
|
||||
`TestUpgradeSkillLocationsMonoDiskMigratesToMulti`)+
|
||||
`internal/app/upgrade_skill_multi_e2e_test.go`。
|
||||
|
||||
## 2. 安装默认 multi(四个脚本面)
|
||||
|
||||
四个脚本安装面默认值全部为 multi,`DWS_SKILL_MODE=mono` 为统一 opt-in,
|
||||
互斥清理双向对称。详见阶段 1 落地说明(`scripts/install.sh` /
|
||||
`install.ps1` / `build/npm/install.js` / `scripts/install-skills.sh`)。
|
||||
|
||||
## 3. `dws skill setup` 默认 multi
|
||||
|
||||
- 非交互未指定 `--mode` 时默认 multi。
|
||||
- 交互选项 multi 在前(默认)、mono 标 legacy。
|
||||
- 仍可用 `dws skill setup --mode mono --yes` **重装**到 mono(这是安装入口,
|
||||
不是 lifecycle 切换产品;无备份/state/rollback 命令)。
|
||||
|
||||
## 4. 文案翻转
|
||||
|
||||
- `README.md` / `README_zh.md`:multi 默认、mono legacy。
|
||||
- `skills/multi/dingtalk-skill/SKILL.md`:去掉 EXPERIMENTAL。
|
||||
|
||||
## 5. 已验证
|
||||
|
||||
- `go test ./internal/upgrade ./internal/app ./test/scripts`(阶段 1 基线)+
|
||||
upgrade-force-multi 单测 / fake-HOME E2E。
|
||||
- 脚本面契约测试暴露 `DWS_SKILL_MODE` 与 mono/multi 选项。
|
||||
|
||||
## 6. 决策记录
|
||||
|
||||
- **D1 升级不依赖 state.json**(仍成立)。不读状态文件;布局由包内容驱动。
|
||||
- **D2 无服务端灰度**。L1 beta 轨 + issue/回访;L2 `rollout.json` 已砍;
|
||||
kill switch = beta 撤回 / 重装 `--mode mono`(无 `skill mode` 命令)。
|
||||
- **D3 生态分发通道已否决**(`npx skills add` 等)。见
|
||||
[skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
- **D4 互斥前缀约定**。`dingtalk-*` / `dws-shared` 属 DWS 产品 skill。
|
||||
- **D5 无运行时模式切换(2026-08-05)**。取消阶段 2 的备份式安装、
|
||||
`state.json`、`dws skill mode`(status/set/rollback)、`x-dws-skill-mode`
|
||||
请求头。Mode 只在安装时决定。
|
||||
- **D6 升级不做粘性(2026-08-05)**。有 multi 包时 upgrade 一次性刷成 multi
|
||||
(含存量 mono);legacy 无 multi 树才回退 mono 路径。
|
||||
|
||||
---
|
||||
|
||||
# 第二部分:Roadmap
|
||||
|
||||
## ✅ 阶段 1 / 1.5(已完成,2026-08-05)
|
||||
|
||||
安装/升级默认 multi、五面互斥清理、文案翻转、1.5 review 修复与实机 9/9。
|
||||
HEAD:`402429ac`。
|
||||
|
||||
## ❌ 阶段 2(原切换/状态/备份产品线)— CANCELLED(2026-08-05)
|
||||
|
||||
| 原任务 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 备份式安装(`~/.dws/skills/backup/...`) | ❌ CANCELLED | 不做运行时切换,无需备份回滚产品 |
|
||||
| `~/.dws/skills/state.json` | ❌ CANCELLED | 无切换产品;upgrade 按包刷 multi,不需要状态文件 |
|
||||
| `dws skill mode` status/set/rollback/--dry-run | ❌ CANCELLED | 无运行时切换 UX |
|
||||
| `x-dws-skill-mode` 请求头 | ❌ CANCELLED | owner 决策移除;观测改 issue/回访 |
|
||||
| agent home 清单门禁 | ⬜ 可选 | 与切换产品无关,仍可作工程质量项 |
|
||||
| `upgrade --dry-run` multi 文案 | ⬜ 可选 | 可随 force-multi 语义轻量对齐 |
|
||||
|
||||
## 重构后的 8/30 目标
|
||||
|
||||
**底线**:新装默认 multi;含 `multi/` 的 release 上 `dws upgrade` **一次性刷成
|
||||
multi**(含存量 mono);仅需保持 mono 的用户用安装入口 opt-in 后勿升级到
|
||||
含 multi 的包,或 retirement 前用 setup 重装;mono 物理删除可另议。
|
||||
|
||||
### 建议关键路径(简化)
|
||||
|
||||
```text
|
||||
阶段1默认multi ✅ → upgrade force-multi ✅ → beta/L1(可选)→ 观察(issue/回访)
|
||||
→ stable 默认已是 multi → 可选 mono retirement(删 mono 树 / 安装入口报错)
|
||||
```
|
||||
|
||||
### mono retirement(原阶段 4,重框)
|
||||
|
||||
- **不再**提供 `dws skill mode rollback` 作为用户出口。
|
||||
- 日常 upgrade 已在有 multi 包时迁走存量 mono;retirement 版本可进一步移除
|
||||
install/setup 的 mono 分支与 zip 内 mono 树。
|
||||
- 下线判据改为:issue/回访无系统性 multi P1、mono opt-in 可接受、政策门
|
||||
不再依赖 `skills/mono/SKILL.md`。(原请求头占比判据作废。)
|
||||
|
||||
## 悟空线下线的影响(2026-08-05)
|
||||
|
||||
悟空 bundled-skill 分发线已下线:mono 下线无仓外节奏闸门;`skills/multi/`
|
||||
为唯一 multi 事实源。设计资产留档
|
||||
[skill-wukong-comparison.md](skill-wukong-comparison.md)。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- ❌ 运行时模式切换(`dws skill mode set/rollback`)。
|
||||
- ❌ `state.json` / 备份式安装产品面(随 D5 取消)。
|
||||
- ❌ `x-dws-skill-mode` 请求头。
|
||||
- ❌ 服务端远程配置 / L2 `rollout.json`。
|
||||
- ❌ 运行时「模式切换」产品(含 sticky 伪切换);upgrade 有 multi 时刷 multi
|
||||
是包驱动的一次性迁移,不是 switch UX。
|
||||
- 不动 `dws-skills.zip` 双树布局(仍可含 mono 副本供安装 opt-in);不动市场
|
||||
skill(`dws skill install`)。
|
||||
|
||||
---
|
||||
|
||||
## 风险表
|
||||
|
||||
| 风险 | 现状 | 缓解 |
|
||||
|---|---|---|
|
||||
| 半装无备份 | 安装/清理仍 `RemoveAll` | 接受为非切换产品下的已知限制;重装可收敛 |
|
||||
| mono/multi 漂移 | 无 state;upgrade 有 multi 即刷 multi | 升级后收敛为 multi;安装互斥清理 |
|
||||
| 用户想换模式 | 无 switch 命令 | 文档引导:`dws skill setup --mode <mono\|multi> --yes` 重装 |
|
||||
| 8/30 滑期 | 切换产品线已砍,关键路径缩短 | 聚焦 force-multi upgrade + 默认 multi 稳定 |
|
||||
@@ -0,0 +1,290 @@
|
||||
# DWS Skill mono→multi 灰度能力设计(rollout capability)
|
||||
|
||||
> 本文回答一个问题:**在没有服务端远程配置/灰度平台的前提下,mono→multi
|
||||
> 默认翻转如何灰度发布、如何观测、如何止血**。关联文档:
|
||||
> [skill-multi-roadmap.md](skill-multi-roadmap.md)(迁移事实源,本文展开其
|
||||
> 阶段 3「灰度切流」)、[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> §3.7(灰度与止血的原始设计)。代码锚点快照:`feat/skill-mode-migration`
|
||||
> 工作区未 commit 变更(2026-08-05)。
|
||||
|
||||
---
|
||||
|
||||
## TL;DR 推荐路线
|
||||
|
||||
| 步 | 动作 | 层级 | 前置 |
|
||||
|---|---|---|---|
|
||||
| 1 | 把「五面默认 multi」拆成**版本门控默认**(beta→multi / stable 观察),同一份代码发 beta 轨先吃 | L1 | 无(本文 §2.1) |
|
||||
| 2 | ~~阶段 2 四件套(备份 / state.json / `dws skill mode` / 请求头)~~ | — | **❌ CANCELLED(2026-08-05)**:无运行时模式切换;upgrade 有 multi 时刷 multi(不做粘性) |
|
||||
| 3 | beta 轨观察:issue 流入 + 主动回访(**无**请求头占比) | 人工 | 步 1 |
|
||||
| 4 | stable:默认 multi 已在阶段 1 落地;L2 `rollout.json` 已砍 | — | — |
|
||||
| 5 | kill switch:beta 撤回(已有)/ 公告重装 `dws skill setup --mode mono --yes` | — | 无备份回滚产品 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点:今天可用于灰度的全部旋钮
|
||||
|
||||
### 1.1 旋钮总表
|
||||
|
||||
| # | 旋钮 | 代码锚点 | 生效范围 | 盲区(谁够不着) |
|
||||
|---|---|---|---|---|
|
||||
| K1 | GitHub Release 双轨(stable / prerelease) | `internal/upgrade/github.go:100-106`(`ReleaseTrack`);`internal/app/upgrade.go:870-875`(`upgradeTrack`);release.yml 强制版本→轨映射(`release.yml:547` 含 `-beta.` → prerelease) | `dws upgrade --beta` / `--version vX.Y.Z-beta.N` 的二进制+skill 升级 | 不用 `dws upgrade` 的人;Gitee/OSS 镜像用户(见 K3/K4) |
|
||||
| K2 | npm dist-tag 双轨(`latest` / `beta`) | `release.yml:1761-1762`(prerelease→`beta` tag);发布防倒退 `release.yml:1828-1835`;撤回脚本 `scripts/release/withdraw-release.sh:652-665`(dist-tag 回拨+deprecate) | `npm i dingtalk-workspace-cli@beta` 的新装/重装 | npm 默认安装(`@latest`)用户无感;npm 装完即走、不再 `npm i` 的存量 |
|
||||
| K3 | Gitee 镜像(代码 + release 资产) | main 代码镜像 `.github/workflows/mirror-to-gitee.yml:42-82`;release 资产镜像 `release.yml:1957-1965`(`sync-to-gitee.sh`);安装脚本侧 `DWS_GITEE_REPO` 解析 `scripts/install.sh:18-19`、`scripts/install-skills.sh:21-24` | curl\|sh / install-skills.sh 的国内用户(显式 env 或 GitHub 不可达自动回退) | **`dws upgrade` 不到 Gitee**:upgrade client 只打 GitHub API(`internal/app/upgrade.go:48` → `internal/upgrade/github.go`,包内无任何 Gitee 引用);Gitee release 是否 prerelease 由镜像脚本原样搬运,无独立轨控 |
|
||||
| K4 | OSS 镜像(ossutil 同步) | `scripts/release/sync-to-oss.sh:9-14`(`download/<version>/` + `latest.txt`/`beta.txt` 指针);`release.yml:1897-1908` | 今天:**只写不读**——脚本注释明示「repository installers currently resolve GitHub/Gitee and do not consume these OSS pointers directly」(`sync-to-oss.sh:5-7`) | 所有人(指针无人消费);但 `latest.txt`/`beta.txt` 是天然的**可变 channel 指针**(见 §2.2 设计复用) |
|
||||
| K5 | 安装脚本 env / flag | `DWS_SKILL_MODE`:`scripts/install.sh:17`(解析 `install.sh:220-256`)、`scripts/install.ps1:293-332`、`scripts/install-skills.sh:29-33`;npm `--skill-mode` / env `build/npm/install.js:197-214`;`DWS_VERSION` 指定 beta 版 `install.sh:38` | 新装时的逐台显式控制(CI、内推灰度名单) | 只对**执行安装那一刻**生效;装完无持久化(无 state.json),事后无法得知当初怎么装的;`DWS_VERSION=latest` 在 GitHub 侧只解析 stable(`/releases/latest` 永不指向 prerelease,`install.sh:190-198`),beta 必须显式给版本号 |
|
||||
| K6 | 版本门控默认值(构建期注入) | goreleaser ldflags 注入版本 `.goreleaser.yaml:22`(`internal/app.version=v{{.Version}}`);`prerelease: auto` `.goreleaser.yaml:68` | 同一 commit,beta build 与 stable build 可表现不同默认值(§2.1 切法 B 的机制) | 脚本面拿不到 Go 变量,需各自从「解析出的版本号」重推导(§2.1);homebrew formula 只搬 zip 根(mono 布局)到 pkgshare(`build/homebrew.rb.tmpl:26-31`),formula 本身无 mode 概念 |
|
||||
| K7 | 本地遥测(opt-in) | `internal/shortcut/usage/recorder.go:82-88`(`DWS_USAGE_TRACKING=1`,默认关);只写本地 `~/.dws/usage.jsonl`(`recorder.go:91`) | 高频命令形状挖掘(shortcut P2) | **不上传任何服务端**:对灰度占比测量零贡献;默认关意味着即使上传也无统计意义 |
|
||||
| K8 | 请求头通道(已有上行链路) | MCP 请求统一注入点 `internal/app/runner.go:953-1008`(`resolveIdentityHeaders`,接线于 `runner.go:140/240/597`、`internal/app/recovery_command.go:271/337`);登录权限检查 `internal/auth/oauth_helpers.go:1424-1428`;`x-dws-channel`(`DWS_CHANNEL`)先例 `runner.go:1006-1008`、`oauth_helpers.go:1425-1426` | 服务端(MCP 网关)已能按 header 聚合:`x-dws-agent-id`、`x-dingtalk-dws-agent-code`、`X-Cli-Version` 均在线 | 只有「已登录且发 MCP 请求」的用户可被观测;纯安装未使用、auth 失败前的用户在分子里缺席(占比偏高估,§2.3) |
|
||||
|
||||
### 1.2 结构性盲区(任何旋钮都够不着)
|
||||
|
||||
| 盲区 | 说明 | 出处 |
|
||||
|---|---|---|
|
||||
| ~~悟空 bundled skill 分发线~~(盲区已移除) | 悟空分发线(dws_res → Wukong.app bundled-skills)已于 2026-08-05 决策下线,本盲区随之移除;历史上该线在**本仓库外**、仅有 main CI 成功后的下游触发(`.github/workflows/notify-wukong.yml:13-38`),本仓库默认值翻转管不到它 | 原 roadmap 风险表「悟空线外挂」行(已解除)、阶段 4 原判据 3(已作废) |
|
||||
| homebrew 用户 | formula 只把 zip 根(mono 副本)stage 进 `pkgshare/skills/dws`(`build/homebrew.rb.tmpl:26-31`),caveats 指向 `dws skill setup`(`:33-38`);multi 源不在包内,`setup --mode multi` 只能靠 `~/.dws/skills/multi` 缓存 | 同上,K6 |
|
||||
| 永不升级的存量 mono 用户 | 所有旋钮都作用于「新装/升级/重装」三个时点;不动作的用户一切照旧(这正是灰度的天然保护层) | — |
|
||||
| 安装后不再运行 `dws` 的用户 | K8 观测不到,占比分母缺失 | §2.3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案设计(分层)
|
||||
|
||||
### 2.1 L1 渠道灰度:beta 轨先吃 multi 默认
|
||||
|
||||
目标:**同一代码、不同 release 轨不同默认值**,stable 用户在观察期内完全无感。
|
||||
|
||||
#### 切法 A:纯流程(零新增代码,不推荐单独使用)
|
||||
|
||||
当前工作区五面已无条件翻转 multi;直接发 beta 即完成「beta 先吃」。
|
||||
问题:main 上的默认值已是 multi,**下一个 stable 无处可躲**——stable 发布
|
||||
窗口一到就必须全切,观察期长短不由人;且中途想给 stable 出补丁版(hotfix)
|
||||
会被迫带上 multi 默认。仅适合「beta 观察期确定短、stable 窗口确定远」的情形。
|
||||
|
||||
#### 切法 B:版本门控默认值(推荐)
|
||||
|
||||
把五面的默认值从「无条件 multi」改为「beta 版本默认 multi,stable 版本默认
|
||||
mono」。判据统一用版本号是否含 `-beta.`(release.yml 已强制版本→轨唯一映射,
|
||||
`release.yml:547`、`.goreleaser.yaml:68`):
|
||||
|
||||
| 面 | 门控取值来源 | 落点 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` / `dws upgrade`(Go) | ldflags 注入的 `internal/app.version`(`.goreleaser.yaml:22`),`strings.Contains(version, "-beta.")` | `internal/app/skill_setup.go:354-357`(非交互默认)与 `:364-368`(交互默认项排序) |
|
||||
| install.sh / install.ps1 / install-skills.sh | 脚本自己解析出的 `$VERSION`(`install.sh:177-198`;Gitee 侧 `install.sh:180-188`)——`case "$VERSION" in *-beta.*)` | `install.sh:220-256`、`install.ps1:293-332`、`install-skills.sh:29` |
|
||||
| npm install.js | 包内 `package.json` 的 `version`(staging 时由 `stage-npm-package.sh` 写入 release 版本) | `build/npm/install.js:197-214` |
|
||||
|
||||
**关键发现——upgrade 路径(2026-08-05 更新)。** skill 升级语义是「跟着 zip
|
||||
产物布局走、不做磁盘粘性」:`LocateSkillsRoot` 恒优先 `multi/`,
|
||||
`UpgradeSkillLocations` 在包内有 multi 时**始终**刷 multi(含存量 mono
|
||||
一次性迁移,清 `dws/`)。若仍要做「stable 观察期不迁 mono」,门控必须落在
|
||||
**是否发布含 multi 的 zip / 是否走 upgrade skill 刷新**,而不是磁盘粘性分支
|
||||
(粘性方案已否决)。当前产品默认接受:含 multi 的 release 上 upgrade = 迁
|
||||
multi。
|
||||
|
||||
切法 B 的安装默认门控(beta→multi / stable→mono)仍可独立存在;与 upgrade
|
||||
force-multi 正交——安装 opt-in mono 的用户一旦升级含 multi 的包会被迁走。
|
||||
|
||||
#### 风险与回退
|
||||
|
||||
| 风险 | 说明 | 回退 |
|
||||
|---|---|---|
|
||||
| 门控不可见 | 默认值随版本号变化,review/测试容易漏 | 每面补契约测试(beta→multi / stable→mono),`test/scripts` 已有同构先例(`test/scripts/install_script_test.go:529-576`) |
|
||||
| 升级迁走 mono | **接受为产品语义**:有 multi 包时 upgrade 一次性刷 multi;需 mono 则 `dws skill setup --mode mono --yes` 重装(装完后再 upgrade 仍可能被迁回) | S1 撤回含 multi 的坏包;S3 公告重装 |
|
||||
| beta 轨整体有毒 | 二进制或 skill 包级事故 | 现有撤回链:`scripts/release/withdraw-release.sh`(GitHub release 撤回 + npm deprecate + dist-tag 回滚,`withdraw-release.sh:652-665`);stable 轨不受影响 |
|
||||
| 版本字符串被仿造 | 本地 `go build` 无版本注入时 `version=""`,门控落 stable 分支(保守方向,正确) | — |
|
||||
|
||||
### 2.2 L2 确定性分桶:随 release 发 `rollout.json`
|
||||
|
||||
L1 的粒度是「轨」:beta 全吃、stable 全不吃。stable 切流若要 5%→100% 的
|
||||
渐进,需要机器级分桶。无服务端,用**随版本分发的只读配置 + 客户端确定性
|
||||
哈希**替代。
|
||||
|
||||
#### rollout.json schema 与发布链路
|
||||
|
||||
作为 release 资产随每个版本发出(进 `dist/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"skill_mode": {
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"note": "mono->multi default rollout for stable track"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pct`:0–100,`bucket < pct` 的机器默认 multi。
|
||||
- `salt`:分桶盐,换盐=重新洗牌(默认不换,保证跨版本粘性可比)。
|
||||
- 发布链路改动:`scripts/release/post-goreleaser.sh` 生成进 `dist/`;
|
||||
**资产命名空间是精确集合**(`scripts/release/verify-release-artifacts.sh:12-38`,
|
||||
「public release assets must contain exactly the supported files」),必须把
|
||||
`rollout.json` 加进 EXPECTED_ASSETS;stable 晋升门会比较 beta 资产集
|
||||
(`release.yml:833-846`),所以引入该资产的那个 beta 起两轨必须同时带。
|
||||
- checksums.txt 由 dist 自动生成,镜像脚本(Gitee `release.yml:1957-1965`、
|
||||
OSS `sync-to-oss.sh:9-14`)整目录搬运,**rollout.json 自动随资产集流到
|
||||
Gitee/OSS,无需额外接线**。
|
||||
|
||||
#### machine-id 来源:读现成,不新建
|
||||
|
||||
`internal/auth/identity.go` 已有稳定 per-install UUID v4 `machineId`
|
||||
(`identity.go:19-20`、结构体 `:70-76`),持久化在 `~/.dws/identity.json`
|
||||
(`identity.go:51` + `pkg/config/constants.go:177-188`),惰性生成
|
||||
(`EnsureExists` `:115-133`),v1 文件透明迁移(`:98-113`)。分桶直接复用:
|
||||
|
||||
```
|
||||
bucket = int(sha256(machineId + "|" + salt)[:8], 16) % 100
|
||||
```
|
||||
|
||||
边界情况:`identity.json` 首建于首次 MCP 请求链路(`runner.go:954`)。灰度
|
||||
决策发生在 setup/upgrade 时,可能早于任何 MCP 请求——此时按 `EnsureExists`
|
||||
同款语义**就地惰性创建**(best-effort 持久化,失败则用进程内随机值且当次
|
||||
不记账,下次重决)。不引入第二套 `~/.dws/install-id`,避免双事实源。
|
||||
|
||||
脚本面(sh/ps1)做 sha256 分桶要读 JSON + 哈希,复杂且易错;npm install.js
|
||||
用 node crypto 是一行。**范围划定:L2 分桶只在 Go 面(`dws upgrade` /
|
||||
`dws skill setup` / 未来 `dws skill mode`)与 npm install.js 实现**;sh/ps1
|
||||
停在 L1 版本门控(curl 用户全是新装,渠道轨已够;百分比分桶的主战场是存量
|
||||
升级,而升级必过 Go 二进制)。
|
||||
|
||||
#### state.json 的 rollout 字段
|
||||
|
||||
依赖阶段 2 的 `~/.dws/skills/state.json`(P0b,未实现,roadmap 阶段 2):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "multi",
|
||||
"rollout": {
|
||||
"bucket": 37,
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"decision": "multi",
|
||||
"decided_at": "2026-08-20T08:00:00Z",
|
||||
"decided_by": "rollout.json@v1.4.2",
|
||||
"explicit": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
决策顺序(每台机器只决策一次,粘性):
|
||||
|
||||
1. 本地显式(`DWS_SKILL_MODE` / `--mode` / `--skill-mode` / `dws skill mode set`)
|
||||
→ 用之,`explicit=true`;
|
||||
2. `state.json` 已有 `mode` 或磁盘形态可反推(roadmap 阶段 2 既定兜底)
|
||||
→ 保持现状,不参与分桶;
|
||||
3. `state.rollout.decision` 已存在 → 复用(pct 后续变化不翻案);
|
||||
4. 拉取 `rollout.json`(Go 面:upgrade 已下载本版资产,同 release 再取一个
|
||||
小文件;npm:包内自带)→ 算 bucket 决策并记账;
|
||||
5. 任一步失败 → 当期默认(L1 版本门控结果)。
|
||||
|
||||
#### 三条硬规则
|
||||
|
||||
| 规则 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| R1 本地显式优先 | env/flag/命令任何时候压过 rollout 决策;显式选择落 `explicit=true` 后 rollout 永不改它 | 灰度不能覆盖用户意志;也是 kill switch 的用户侧出口 |
|
||||
| R2 拉取失败 fail-safe | 拉不到/解析失败/字段越界 → 保持现状(存量)或当期默认(新装),**绝不因拉取失败翻模式** | 无服务端下网络面即故障面,故障必须倒向保守侧 |
|
||||
| R3 存量不被改模式 | 已有 mode(state 或磁盘可推)的机器不参与分桶;pct 只影响「未决策」机器 | pct 从 20 降到 0 不能把已进 multi 的 20% 弹回 mono(那需要 kill switch,不是分桶语义) |
|
||||
|
||||
#### 与 Gitee / immutable release 的兼容
|
||||
|
||||
- **Gitee**:`dws upgrade` 不读 Gitee(§1.1-K3),rollout.json 经
|
||||
reconcile/sync 脚本随资产集镜像到 Gitee release;Gitee 侧安装脚本如需消费,
|
||||
走与 `dws-skills.zip` 相同的 Gitee API 资产枚举(`install.sh:180-188` 同
|
||||
模式)。一期不消费、只保证镜像不缺失。
|
||||
- **immutable release 约束**:官方仓已开启不可变 release(`release.yml:802`
|
||||
「Immutable releases must be enabled before publishing」),**资产发布后不可
|
||||
替换**——调 pct = 发一个新补丁版(beta 线 release.yml 支持连续 beta:
|
||||
`release_bump` 在连续 beta 线时被忽略,`release.yml:25-27`)。这决定了
|
||||
L2 的调参时延 = 一次发版;追求更快止血见 §2.4。
|
||||
- (可选远期)OSS `latest.txt`/`beta.txt` 是现成的**可变**指针
|
||||
(`sync-to-oss.sh:13-14`),若未来 installer 学会读 OSS,可把
|
||||
`rollout-current.json` 放 OSS 变指针后面实现「不发版调 pct」;今天无消费
|
||||
方,不建。
|
||||
|
||||
### 2.3 L3 可观测性:`x-dws-skill-mode` 请求头 — ❌ CANCELLED
|
||||
|
||||
**2026-08-05 owner 决策:不实现该请求头**(与运行时模式切换 / state.json
|
||||
一并取消)。原设计(注入 `resolveIdentityHeaders`、按 state/磁盘上报
|
||||
`mono|multi|unknown`)仅作历史记录,不进入排期。
|
||||
|
||||
观察手段改为:**issue 反馈 + 主动回访**;不再有请求级 multi 占比判据。
|
||||
|
||||
### 2.4 Kill switch:四层止血
|
||||
|
||||
| 层 | 手段 | 时延 | 现状/依赖 |
|
||||
|---|---|---|---|
|
||||
| S1 beta 轨整体撤回 | `scripts/release/withdraw-release.sh`:GitHub release 撤回 + npm deprecate + `beta` dist-tag 回拨(`:652-665`) | 分钟级 | **今天可用** |
|
||||
| S2 rollout.json `pct=0` | 发补丁版把 pct 打 0(immutable release 不允许原地改资产,§2.2) | 一次发版(小时级) | 依赖 L2 落地 |
|
||||
| S3 公告命令 | 公告用户重装 `dws skill setup --mode mono --yes`(安装入口,非 switch 产品) | 用户触达时延 | **可用**(无 `dws skill mode`;阶段 2 切换命令已 CANCELLED) |
|
||||
| S4 备份回滚 | ~~`dws skill mode rollback` + 备份式安装~~ | — | **❌ CANCELLED(2026-08-05)** 与运行时切换一并取消 |
|
||||
|
||||
二进制侧另有既有的 `dws upgrade --rollback`(`internal/upgrade/rollback.go`,
|
||||
备份在 `~/.dws/data/backups`、保留 5 份 `:16/:54-63`),但只回滚二进制不回滚
|
||||
skill 布局——skill 止血靠 S1 + S3(重装 mono),**无** S4。
|
||||
|
||||
**结论(2026-08-05):** kill switch = S1(beta 撤回)+ S3(公告重装
|
||||
`--mode mono`)。S4 已取消;日常 upgrade 有 multi 包时**一次性刷 multi**
|
||||
(不做粘性),故「装完 mono 再 upgrade」会迁走——止血靠撤回坏包或重装。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对标(简要)
|
||||
|
||||
**npm dist-tag 双轨(lark-cli 类企业内部 CLI 的通行形态)。** 以 npm 仓
|
||||
registry 为唯一分发面时,灰度即 dist-tag:`latest` 稳态、`beta`/`next` 先行
|
||||
(`next@canary`、`typescript@beta` 同款模式),安装侧 `npm i pkg@beta` 或
|
||||
CI 指定 tag 即完成分群;撤回即 `npm dist-tag add pkg@<prev> latest` +
|
||||
`npm deprecate`。DWS 已完整具备此形态(§1.1-K2),lark-cli 等内部 CLI 在
|
||||
集团内网 registry 上亦按同一范式运作——差别只在内部 registry 可附带按
|
||||
员工/部门灰度的下发规则,那是「registry 有服务端」的红利,DWS 面向公网
|
||||
npm 没有这一层,故需 L2 补齐。
|
||||
|
||||
**安装时下载器内版本选择(deno / rustup 模式)。** `curl | sh` 安装器不显式
|
||||
给版本时,先拉一个**可变 channel 指针文件**(如 deno 的
|
||||
`dl.deno.land/release-latest.txt`、rustup 的 channel manifest),再按指针下载
|
||||
真实产物——指针一改全量新装即转向,**不发版即可调流**。DWS 的 OSS
|
||||
`latest.txt`/`beta.txt`(`sync-to-oss.sh:13-14`)已是同构物,只差安装脚本消费
|
||||
它;install.sh 今天直接打 GitHub `/releases/latest` 重定向(`install.sh:190-198`),
|
||||
等价于把 GitHub 当不可调指针用。此模式是指针级灰度,做不到机器级百分比,
|
||||
需与 L2 分桶叠加。
|
||||
|
||||
**双产物并行(VS Code Stable / Insiders 模式)。** 两个渠道各发各的包、用户
|
||||
自选安装,灰度靠「渠道人口结构」自然形成,无需任何运行时门控。DWS 的
|
||||
GitHub prerelease + npm `@beta` 已是它的轻量版(同包不同 tag 而非两个包名),
|
||||
L1 切法 B 的版本门控默认正是把「渠道差异」从纯流程下沉为可测试的代码事实。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐路线与改动点清单
|
||||
|
||||
### 4.1 路线(与 roadmap 阶段 2/3 对齐后的排序)
|
||||
|
||||
```
|
||||
L1 版本门控拆分(本工作区之上叠加,先合入)
|
||||
→ ~~阶段 2 四件套~~ ❌ CANCELLED(无运行时切换;upgrade force-multi)
|
||||
→ beta 轨发版先吃(L1 自动生效)+ L3 观察 ≥2 周
|
||||
→ stable 切流:小步直接 100%(删门控);若要求渐进再上 L2 分桶
|
||||
→ mono retirement 判据(roadmap:issue/回访,**无**请求头占比)
|
||||
```
|
||||
|
||||
### 4.2 改动点清单(文件级)
|
||||
|
||||
| 项 | 文件 | 改动 | 估时 |
|
||||
|---|---|---|---|
|
||||
| L1-a Go 门控函数 | `internal/app/skill_setup.go`(或新 `internal/upgrade/track.go` 下沉共享) | `defaultSkillModeForVersion(version)`:含 `-beta.`→multi 否则 mono;替换非交互默认与交互排序 | 0.5d |
|
||||
| L1-b upgrade force-multi(已落地) | `internal/upgrade/paths.go` `UpgradeSkillLocations` | 有 multi→始终 multi(含 mono 盘迁移);legacy 无 multi→mono | ✅ |
|
||||
| L1-c 脚本面门控 | `scripts/install.sh` / `install.ps1` / `install-skills.sh` / `build/npm/install.js` | 默认值解析加 `*-beta.*` 分支(若仍做 L1) | 0.5d |
|
||||
| L1-d 契约测试 | `test/scripts` / `internal/upgrade` / `internal/app` | 每面断言 beta→multi;upgrade mono→multi E2E | 0.5–1d |
|
||||
| L3 请求头 | — | **❌ CANCELLED** | — |
|
||||
| L2-a/b/c rollout.json | — | **已砍**(roadmap) | — |
|
||||
| S3/S4 | — | S3=重装 mono(可用);S4 备份/rollback **❌ CANCELLED** | — |
|
||||
|
||||
合计:L1 ≈ 2–2.5d;L3 ≈ 0.5d;L2 ≈ 2.5–3d(在 state.json 之后)。
|
||||
L1+L3 是进入 beta 观察期的最小集;L2 只在 stable 需要渐进切流时才启动,
|
||||
否则删门控一步到位即可。
|
||||
|
||||
### 4.3 明确不做(沿用 roadmap/D2,本文补充)
|
||||
|
||||
- 不建服务端远程配置/灰度平台;不为灰度单独引入可变配置下发通道(OSS 变
|
||||
指针仅作远期可选,今天无消费方)。
|
||||
- 不动 `dws-skills.zip` 产物布局(D1);不按轨发不同 zip——轨差异全部落在
|
||||
版本门控的客户端行为上。
|
||||
- 不用遥测做灰度测量(K7 本地 opt-in 无统计意义),观测只走 K8 请求头。
|
||||
@@ -0,0 +1,340 @@
|
||||
# DWS multi-skill vs 悟空(dws-wukong)分发线对比
|
||||
|
||||
> ⚠️ **留档注记(2026-08-05)**:悟空(dws-wukong)bundled-skill 分发线
|
||||
> 已于 2026-08-05 决策下线。本文自此仅作**历史调研留档**,不再作为任何
|
||||
> 对齐依据——文中的「判据 #3」「对齐清单(§7.1)」「待确认问题(§7.2)」
|
||||
> 等均随悟空线下线而 MOOT。其中 bundle 的自描述打包设计
|
||||
> (`manifest.json` + `scripts/_install.sh`)与 symlink 提升消费方式
|
||||
> 可作为未来打包方案参考保留。
|
||||
>
|
||||
> 撰写日期:2026-08-05。聚焦 **multi-skill** 主题:DWS 侧(本工作区
|
||||
> `feat/skill-mode-migration`)已把 multi 翻转为全通道默认,而 mono 下线
|
||||
> 原判据 #3 曾要求"悟空 bundled skill 分发线(dws_res → bundled-skills)
|
||||
> 已切 multi"([skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4、
|
||||
> [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §5;
|
||||
> 该判据已于 2026-08-05 随悟空线下线作废)。
|
||||
> 本文盘清悟空线现状、两边差异、切换影响与对齐清单。
|
||||
>
|
||||
> 配套阅读:[skill-multi-roadmap.md](skill-multi-roadmap.md)(DWS 侧
|
||||
> as-implemented 事实源)、[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
> (DWS 分发/消费链路调研)。
|
||||
|
||||
## 0. 摘要(TL;DR)
|
||||
|
||||
- **DWS 侧**:multi(`skills/multi/`,19 个 `dingtalk-*` 产品 skill +
|
||||
`dws-shared`)已是安装(4 脚本面)、升级、`dws skill setup` 五面默认;
|
||||
产物 `dws-skills.zip` 恒含"根 mono 副本 + `mono/` + `multi/`"三树,
|
||||
二进制 embed 双树(`skills_embed.go:28`)。**产物零改动即完成默认翻转**。
|
||||
- **悟空侧**:multi 打包能力**已合入 dws-wukong `develop`**(merge
|
||||
`9eb801e5`,"DWS MultiSkill 与 Qwen Work Cloud 六平台打包"),但
|
||||
**正式发版 target `make real-platform` 仍只打 mono**
|
||||
`dingtalk-workspace.zip`;multi 以 `dingtalk-workspace-bundle.zip` 双包
|
||||
形态存在(`bundle-platform` / `package-dual`),本地有 2026-07-03 的双包
|
||||
实测产物,但**未随已发布版本出门**(`release/0.2.97`–`0.2.99` 均不含该
|
||||
merge)。
|
||||
- **关键缺口在客户端**:RewindDesktop(悟空桌面端)构建期
|
||||
`download_binary.py` 只认 `dingtalk-workspace.zip` 单 zip;运行时
|
||||
`dws_update.rs` 灰度更新也只 upsert 单个 `dingtalk-workspace` skill;
|
||||
全仓 grep 无 `dingtalk-workspace-bundle` / T4b 处理。**端内尚无消费
|
||||
multi bundle 的代码路径**。
|
||||
- **悟空线的 multi 与 DWS 的 multi 是两套独立维护的树**(dws-wukong
|
||||
`dingtalk-skills/` 12 产品 + `dws-shared`,由本仓 mono 机械派生;DWS
|
||||
`skills/multi/` 19 产品 + `dws-shared`),内容靠 SOP 人工对齐,无自动
|
||||
同源。判据 #3 的"切 multi"首先要解决的是**打包形态 + 端内加载**,
|
||||
内容同源是紧随其后的问题。
|
||||
- 结论(历史):判据 #3 远未满足。对齐需要 dws-wukong 仓(发版 target)、
|
||||
RewindDesktop 仓(构建期 + 运行时两条加载路径)两侧改动,详见 §7 清单。
|
||||
**(2026-08-05 MOOT:悟空线下线,判据 #3 已作废——见
|
||||
[skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4 判据更新;
|
||||
上述对齐工作不再需要。)**
|
||||
|
||||
## 1. 证据源与版本快照
|
||||
|
||||
本地仓库(均为真实磁盘证据,非仅凭文档):
|
||||
|
||||
| 仓库 | 本地路径 | 核查时状态 |
|
||||
|---|---|---|
|
||||
| DWS(本仓) | `~/GolandProjects/open-source/dws-skill-mode-migration` | `feat/skill-mode-migration`,含未 commit 的阶段 1 变更 |
|
||||
| dws-wukong 主仓 | `~/GolandProjects/open-source/dws-wukong` | checkout `codex/deploy-qwenwork-dev`(落后 develop 602 commits);本文 Makefile/脚本结论均以 `develop` 分支内容(`git show develop:...`)为准 |
|
||||
| dws-wukong multiSkill 工作区 | `~/GolandProjects/open-source/dws-wukong-multiSkill` | detached @ `9eb801e5`(multiSkill merge 本体),`target/` 有 2026-07 实测产物 |
|
||||
| RewindDesktop | `~/IdeaProjects/RewindDesktop` | checkout `dws/0.2.98`;`develop` 上 `DEFAULT_DWS_RES_URL` = pod `0.2.96` |
|
||||
|
||||
关键版本事实:
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| multiSkill merge `9eb801e5` 已入 `develop` 与本地 `release/0.2.100` | `git branch --contains 9eb801e5` |
|
||||
| `release/0.2.97` / `0.2.98` / `0.2.99` **不含** multiSkill merge;其 `real-platform` 不打 bundle | `git merge-base --is-ancestor` + `git show origin/release/0.2.99:Makefile` |
|
||||
| 当前发给悟空的 pod 包为 mono:`dws_res_mac.zip` 内仅 `dingtalk-workspace.zip`(单 skill,`SKILL.md`+`references/products/*`)+ 双架构二进制 | 主仓 `target/dws_res_mac.zip`(2026-06-01)`unzip -l` 实测 |
|
||||
| 双包形态已实测:`dws_res_mac/` 同时含 `dingtalk-workspace.zip`(924K)与 `dingtalk-workspace-bundle.zip`(1.27M) | multiSkill 工作区 `target/dws_res_mac.zip`(2026-07-03)实测 |
|
||||
| multi bundle 内部布局:`manifest.json` + `scripts/_install.sh` + `skills/<name>/SKILL.md...` 平铺目录 | multiSkill 工作区 `target/dingtalk-workspace.zip`(2026-07-24)实测 |
|
||||
|
||||
**本地未能验证**(详见 §7.2 问题清单):pod 线上当前包内容(需内网
|
||||
SSO);`/Applications/Wukong.app` 未安装在本机(bundled-skills 目录不存在,
|
||||
无法核对在端真实 zip);RewindDesktop 端"T4b 二选一"灰度逻辑(全仓无匹配,
|
||||
疑似未开发或在平台侧);Qwen Work Cloud 六平台打包的实际发布状态。
|
||||
|
||||
## 2. 分发链路对比
|
||||
|
||||
### 2.1 链路全景
|
||||
|
||||
DWS 侧(本仓,multi 已默认):
|
||||
|
||||
```text
|
||||
skills/mono/ ─┬─ go:embed all:skills/mono all:skills/multi (skills_embed.go:28)
|
||||
skills/multi/ ┘ │ `dws skill setup` 默认源(embed 优先)
|
||||
│
|
||||
└─ scripts/release/post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
(根 = mono 副本 + mono/ + multi/,三树恒含)
|
||||
│
|
||||
GitHub Release / Gitee / OSS / npm tarball / Homebrew / 专项脚本
|
||||
│
|
||||
install.sh · install.ps1 · install-skills.sh · npm install.js(四面默认 multi)
|
||||
+ `dws skill setup`(第五面默认 multi)+ `dws upgrade`(布局探测→multi 刷新)
|
||||
│
|
||||
各 agent home 平铺 dingtalk-*/(互斥清理 dws/ 与过期 skill)
|
||||
+ ~/.dws/skills/{mono,multi} 双缓存
|
||||
```
|
||||
|
||||
悟空侧(dws-wukong + RewindDesktop,发版线仍 mono):
|
||||
|
||||
```text
|
||||
上游 CLI 仓 ../dingtalk-workspace-cli(go.mod replace,sync-upstream 按
|
||||
CLI_UPSTREAM_TAG 重建 release 分支)──► 只提供 Go 代码,不提供 skill 内容
|
||||
|
||||
dws-wukong 仓内 skill 内容(自维护):
|
||||
dingtalk-workspace/(mono 源,含 overlays/real)
|
||||
├─ build-workspace-zip ──► dingtalk-workspace.zip(mono,单 skill)
|
||||
└─ scripts/sync-monolith-to-multiskill.py ──► dingtalk-skills/(multi 派生树)
|
||||
└─ scripts/build-bundle.sh ──► dingtalk-workspace-bundle.zip
|
||||
(manifest.json + scripts/_install.sh + skills/<name>/)
|
||||
|
||||
make real-platform(发版默认)──► dws_res_{mac,win}.zip
|
||||
= dws 二进制 + dingtalk-workspace.zip(仅 mono)
|
||||
make bundle-platform / package-dual(已合入 develop,未用于正式发版)
|
||||
= dws 二进制 + dingtalk-workspace.zip(mono 兜底)+ dingtalk-workspace-bundle.zip
|
||||
│
|
||||
pod.alibaba-inc.com zipUpload(SSO 浏览器上传,版本号独立递增)
|
||||
│
|
||||
RewindDesktop scripts/download_binary.py(DEFAULT_DWS_RES_URL 手工对齐)
|
||||
│
|
||||
Wukong.app Contents/Resources/resources/
|
||||
├─ dws/bin/dws(二进制)
|
||||
└─ bundled-skills/dingtalk-workspace.zip(原样拷贝的单 zip)
|
||||
│
|
||||
运行时两条路:
|
||||
a) 启动同步 initialize_bundled_skills_from_resources
|
||||
→ 解 zip 到中央技能库 ~/.real/.skills/bundled/dingtalk-workspace
|
||||
(及 ~/.real/users/*/.skills/bundled)
|
||||
b) 灰度自更新 dws_update.rs:Gaea 开关 wukong/dws_auto_update_enabled_v2
|
||||
→ LWP /r/Adaptor/DwsGrayI/getLatest 取 {version,url,sha256}
|
||||
→ 下载 dws_res → 换 seed 二进制 + 换 bundled zip + upsert 单 skill
|
||||
```
|
||||
|
||||
### 2.2 链路对照表
|
||||
|
||||
| 环节 | DWS(本仓) | 悟空线 | 锚点(悟空侧) |
|
||||
|---|---|---|---|
|
||||
| skill 事实源 | `skills/mono` + `skills/multi`(同仓双树,multi 19 产品 + dws-shared) | dws-wukong 仓 `dingtalk-workspace/`(mono 源)→ 派生 `dingtalk-skills/`(12 产品 + dws-shared);**与上游 skills/ 无自动同步** | `scripts/sync-monolith-to-multiskill.py` docstring |
|
||||
| 二进制与 skill 的版本耦合 | embed 进二进制,天然同版 | 二进制来自上游 tag(`go.mod:58` replace + `sync-upstream` pin `CLI_UPSTREAM_TAG`);skill 在 dws-wukong 仓随 `VERSION`/`main.go` 双写发版 | dws-wukong `Makefile` `sync-upstream` |
|
||||
| 打包产物 | `dws-skills.zip`:根 mono 副本 + `mono/` + `multi/`(`post-goreleaser.sh:220-248`);embed 双树 | `dws_res_{mac,win}.zip`:二进制 + `dingtalk-workspace.zip`(mono);双包 target 已存在但未上发版线 | dws-wukong `Makefile` `real-platform` / `bundle-platform` / `package-mac-dual` |
|
||||
| 渠道 | GitHub/Gitee/OSS/npm/Homebrew/专项脚本,7 个安装面 | pod zipUpload(SSO)→ RewindDesktop `download_binary.py` → 客户端 bundle | release skill 文档 + `download_binary.py:72-84` |
|
||||
| 端内安装 | 4 脚本 + `dws skill setup` 平铺到 16 个 agent home(父目录门控) | 构建期拷贝 zip 进 app 资源;启动时解到 `~/.real/.skills/bundled/` | `startup.rs:486-554` |
|
||||
| 运行时更新 | `dws upgrade`(布局探测→multi 刷新 + 互斥清理 + 缓存刷新) | 客户端灰度自更新(Gaea + LWP),整包替换 seed 二进制 + skill zip | `dws_update.rs:107,27-28,440-545` |
|
||||
| 灰度能力 | 无服务端:beta 轨(L1)+ 可选 `rollout.json` 分桶(L2)+ 公告 kill switch(决策 D2) | 有服务端:Gaea 开关 + LWP getLatest + pod 版本号 | 决策记录 D2 vs `dws_update.rs` |
|
||||
|
||||
## 3. skill 布局对比
|
||||
|
||||
### 3.1 三种形态
|
||||
|
||||
| 形态 | 布局 | 消费方 |
|
||||
|---|---|---|
|
||||
| DWS mono | 单 skill:`SKILL.md` + `references/` + `scripts/`,装进 `<agent-home>/dws/` | DWS legacy 安装面;悟空 `dingtalk-workspace.zip` 同构(多 `plugins/`、real overlay) |
|
||||
| DWS multi | 平铺目录树:`multi/dingtalk-<product>/{SKILL.md,references,scripts}` + `multi/dws-shared/`,无 manifest、无安装器,拷贝即平铺到 agent home | DWS 五面默认;`dws upgrade` 探测 `multi/` 树(`internal/upgrade/paths.go:363-369`) |
|
||||
| 悟空 multi bundle | zip 内 `manifest.json`(`{"version":...}`)+ `scripts/_install.sh` + `skills/<name>/` 平铺目录(含 `dws-shared`) | **Qwen Work Cloud 已消费**(`_install.sh` 把 `skills/*` 以 symlink 提升到一层 skills 根,供 Codex/OpenCode 扫描;`.dws-multiskill-current` + `.dws-multiskill-links` 记账);**Wukong.app 尚未消费** |
|
||||
|
||||
注意两种 multi 的"平铺"语义不同:DWS multi 是**裸目录树**,由安装面自己
|
||||
拷贝到各 agent home;悟空 bundle 是**自描述包**(manifest + 安装脚本),
|
||||
由消费方解包后提升。`dws-skills.zip` 的 `multi/` 树与
|
||||
`dingtalk-workspace-bundle.zip` 的 `skills/` 树**布局同构但内容不同源**
|
||||
(见 §3.3)。
|
||||
|
||||
### 3.2 悟空客户端加载约定(RewindDesktop 实测)
|
||||
|
||||
构建期(`scripts/download_binary.py`):
|
||||
|
||||
- `DWS_RES_WORKSPACE_ZIP = "dingtalk-workspace.zip"`(`:103`),
|
||||
`resolve_dws_res_contents`(`:1366-1380`)强制 dws_res 内必须同时有
|
||||
平台二进制和**这个文件名的 zip**——`dingtalk-workspace-bundle.zip` 会被
|
||||
原样忽略(不报错,但也不使用)。
|
||||
- `sync_dingtalk_workspace_bundle`(`:1422-1432`)把该 zip **原样拷贝**到
|
||||
`tauri-app/src-tauri/resources/bundled-skills/dingtalk-workspace.zip`,不解包。
|
||||
|
||||
运行时(`tauri-app/src-tauri/src/skills/startup.rs`):
|
||||
|
||||
- `collect_bundled_skill_sources`(`:486-506`)扫描 `resources/bundled-skills/`:
|
||||
**每个子目录或每个 `.zip` = 一个 skill**,`skill_id = 文件主干名`
|
||||
(`dingtalk-workspace.zip` → skill id `dingtalk-workspace`)。
|
||||
该扫描**天然支持多 skill 平铺**(放 `dingtalk-mail.zip`、`dingtalk-doc.zip`
|
||||
就会被分别注册),但**不认识嵌套 bundle**:若把
|
||||
`dingtalk-workspace-bundle.zip` 丢进去,只会被当成一个名叫
|
||||
`dingtalk-workspace-bundle` 的单 skill 解开(内容是 manifest+skills/ 目录,
|
||||
不会被提升)。
|
||||
- `sync_bundled_skill_source`(`:533-554`)解 zip 拷贝进中央技能库;
|
||||
`cleanup_removed_bundled_skills`(`:508-531`)会删除 bundled-skills 里
|
||||
已不存在的 bundled skill(mono→multi 切换时可自动清掉旧
|
||||
`dingtalk-workspace`,前提是 store 记录完好)。
|
||||
- 灰度自更新 `dws_update.rs:440-545` 硬编码单 skill:
|
||||
`upsert_bundled_skill_from_source(store, DWS_WORKSPACE_SKILL_ID, …)`
|
||||
(`DWS_WORKSPACE_SKILL_ID = "dingtalk-workspace"`),换包 = 替换
|
||||
`bundled-skills/dingtalk-workspace.zip` + 重 upsert 这一个 skill。
|
||||
|
||||
结论:端内**构建期与运行时两条路都按"单 zip 单 skill"接线**;要支持
|
||||
multi,二选一:(a) 端内学会解 bundle(manifest + 提升子 skill),或
|
||||
(b) 打包侧把每个子 skill 打成独立 zip 平铺进 dws_res,复用现有平铺扫描
|
||||
(仅需把 `dws_update.rs` 的单 skill upsert 改为遍历)。详见 §6.2。
|
||||
|
||||
### 3.3 两套 multi 树的集合差异
|
||||
|
||||
| | DWS `skills/multi/` | dws-wukong `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 产品 skill 数 | 19 | 12 |
|
||||
| 共有(12 个) | aisearch, aitable, calendar, chat, contact, doc, drive, mail, minutes, misc, todo, wiki | 同左 |
|
||||
| 仅 DWS 有(7 个) | dev, event, hrbrain, markdown, pat, profile, skill | — |
|
||||
| 共享层 | `dws-shared` | `dws-shared`(内容独立维护) |
|
||||
| 内容来源 | 本仓直接维护 | 由本仓 mono `dingtalk-workspace/` 经 `sync-monolith-to-multiskill.py` 机械派生(链接改写 + misc 桶归并) |
|
||||
| 场景 skill | 不涉及 | `dingtalk-products-skills/` 23 个 scenario skill **仅保留源码,不进统一发布包**(`build-bundle.sh` 头注释) |
|
||||
|
||||
含义:即使悟空线明天切到 multi 形态,其 multi **内容**与 DWS multi 也不
|
||||
一致(少 7 个产品、各自演化)。判据 #3 只要求"分发线切 multi"(形态),
|
||||
但长期看内容同源(或明确的子集契约)需要一并决策。
|
||||
**(2026-08-05 MOOT:悟空线下线,"DWS skills/multi(19) vs 悟空
|
||||
dingtalk-skills(12) 分歧"的长期统一问题随之作废,无需统一;DWS
|
||||
`skills/multi/` 成为唯一 multi 事实源。)**
|
||||
|
||||
## 4. 版本对齐对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| skill↔CLI 耦合 | embed 进二进制,`dws skill setup` 装的就是本二进制版本(`skills_embed.go` + `skill_setup_embed.go`) | 二进制版本 = 上游 tag(`sync-upstream` pin);skill 版本 = dws-wukong `Makefile VERSION` + `main.go version` 双写;**两者只通过"同一次发版动作"对齐,无结构性强约束** |
|
||||
| 产物版本 | `dws-skills.zip` 随 goreleaser 与二进制同 tag 发布 | pod 版本号独立于 dws 版本(右most 段 +1 递增,mac/win 各自一条线,如 mac `0.2.28.0`、win `0.2.2`);RewindDesktop `DEFAULT_DWS_RES_URL` 手工改指 |
|
||||
| 端内版本事实源 | — | 客户端以 `dws --version` 输出为准(`get_dws_version_sync`,失败回退 `versions.json`);skill zip 无独立版本概念(mono zip 内无 manifest) |
|
||||
| multi 包版本 | 无 manifest;版本 = 所属 zip/二进制版本 | bundle 内 `manifest.json` 带 `version`(`build-bundle.sh` 由 `$(VERSION)` 写入)——**multi 形态反而第一次给 skill 包带来了显式版本号** |
|
||||
| 升级时的布局兼容 | 新 zip 恒含 `multi/`,`LocateSkillsRoot` 优先 multi;老 zip 自然落回 mono(决策 D1,产物零改动) | dws_res 布局固定(二进制 + workspace zip);双包形态下 mono zip 保留作兜底(`package-mac-dual` 注释:"端内 validate 必含,bundle 缺失时兜底") |
|
||||
|
||||
## 5. 更新 / 回滚对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| 更新触发 | 用户主动 `dws upgrade` / 重装脚本 | 两条:(a) 随客户端版本更新(app 内嵌资源替换 + 启动同步);(b) 运行时灰度自更新(Gaea `dws_auto_update_enabled_v2` + LWP getLatest → 下载整包 → 换 seed 二进制 + 换 bundled zip + upsert skill) |
|
||||
| skill 刷新语义 | 布局探测(multi 优先)+ 按 home 互斥清理(删 `dws/`、过期 `dingtalk-*`/`dws-shared`),清理失败则该 home 不装,杜绝共存(`internal/upgrade/paths.go:217-299`) | 启动同步按 bundled-skills 现状全量对账(新增拷贝、缺失删除,`startup.rs:508-554`);自更新路径是单 zip 替换 + 单 skill upsert(`dws_update.rs:462-513`) |
|
||||
| 回滚 | `dws upgrade --rollback` 只回二进制不回 skill;备份式安装 + `dws skill mode rollback` 是阶段 2 P0,**尚未落地** | 无显式回滚命令;事实回滚 = Gaea 开关关闭/改指旧 pod 版本重新下发,或客户端版本回退;`replace-wukong-skill.sh`(仅存在于 `codex/deploy-qwenwork-dev` 分支)提供人工替换 + 重启 |
|
||||
| 半装保护 | 现状 `RemoveAll` 直删、失败仅 warning(风险表已列,阶段 2 改备份式) | zip 整体替换 + preflight 可写性检查,失败提示重启;粒度为整个 skill 包,无子 skill 级半装概念 |
|
||||
| 离线/内网 | embed 兜底(无网可 setup) | app 内嵌 zip 兜底;灰度通道依赖内网 LWP/pod 可达 |
|
||||
|
||||
## 6. 切换影响分析
|
||||
|
||||
### 6.1 DWS 切 multi 默认 / 最终删 mono 对悟空线的影响点
|
||||
|
||||
| # | 影响点 | 评估 |
|
||||
|---|---|---|
|
||||
| 1 | DWS 删 `skills/mono` 后上游 embed 只剩 multi;悟空二进制由上游 tag 构建,`dws skill setup` 行为随之变 | **低**。悟空客户端不从 embed 装 skill(走 bundled zip);但悟空用户在端内手动跑 `dws skill setup` 时会得到 multi——行为变化需在悟空侧公告 |
|
||||
| 2 | `dws-skills.zip` 布局(根 mono + mono/ + multi/) | **零影响**。悟空线不消费 `dws-skills.zip`;DWS 侧也已承诺不动该产物(决策 D1、"明确不做") |
|
||||
| 3 | dws-wukong 的 mono 内容源 `dingtalk-workspace/` 与上游 `skills/mono` 本就各自维护 | **低(但需注意)**。上游删 mono 不会直接打破 dws-wukong 构建;但两边 mono 的"内容漂移对照基准"消失,dws-wukong mono 将彻底成为孤儿副本,加速与上游 CLI 能力的文档漂移 |
|
||||
| 4 | mono 下线判据 #3 反向卡住 DWS 侧进度 | **高(流程性)**。悟空线一天不切 multi,DWS 就不能物理删 `skills/mono/`(roadmap 风险表"悟空线外挂"行)。**(已解除 2026-08-05:悟空线下线,判据 #3 作废,DWS 侧进度不再受仓外闸门约束)** |
|
||||
|
||||
### 6.2 悟空线"吃 multi"的改造选项
|
||||
|
||||
**选项 A:端内解 bundle(dws-wukong 现有 bundle 产物直接被消费)**
|
||||
|
||||
- dws-wukong 侧:`real-platform` 改打(或加打)`dingtalk-workspace-bundle.zip`
|
||||
——`bundle-platform` / `package-dual` 已就绪,基本零新开发。
|
||||
- RewindDesktop 侧(主要工作量):
|
||||
- `download_binary.py`:`resolve_dws_res_contents` 接受/校验
|
||||
`dingtalk-workspace-bundle.zip`,同步进 `resources/bundled-skills/`;
|
||||
- `startup.rs`:识别 bundle(`manifest.json` + `skills/*`),把每个子
|
||||
skill 注册为独立 bundled skill(逻辑等价于 `_install.sh` 的提升,
|
||||
但落在中央技能库);
|
||||
- `dws_update.rs`:灰度自更新从"单 skill upsert"改为"bundle 全量对账"
|
||||
(可复用启动同步的对账逻辑)。
|
||||
- 优点:与 Qwen Work Cloud 已消费的包形态一致,一份产物两个端;bundle 自
|
||||
带 `manifest.json` 版本号。
|
||||
- 缺点:端内要新增 bundle 解析/提升代码与测试;`skills/` 嵌套布局与现有
|
||||
"一 zip 一 skill"约定不同,需谨慎处理迁移期(旧 mono skill 清理)。
|
||||
|
||||
**选项 B:打包侧拆 zip(端内零新格式)**
|
||||
|
||||
- dws-wukong 侧:新增 target 把 `dingtalk-skills/` 每个子 skill 打成独立
|
||||
zip(`dingtalk-mail.zip` … `dws-shared.zip`)平铺进 dws_res。
|
||||
- RewindDesktop 侧:`download_binary.py` 改为同步多个 zip;
|
||||
`startup.rs` 现有平铺扫描**零改动**(自动注册每个 zip);
|
||||
`dws_update.rs` 仍需从单 skill upsert 改为遍历。
|
||||
- 优点:复用端内现有"一 zip 一 skill"约定,启动路径几乎不动。
|
||||
- 缺点:与 Qwen Work 的 bundle 形态分叉(一份内容两种包);dws_res 内文件
|
||||
数膨胀;`dws-shared` 作为独立 skill id 出现在用户可见列表里需要确认
|
||||
端内展示策略。
|
||||
|
||||
**选项 C(过渡态,事实已在用)**:双包并存——mono zip 兜底 + bundle 灰度,
|
||||
端内按灰度二选一。`package-dual` 的注释已写明此意图("端内 T4b 二选一,
|
||||
monolith 端内 validate 必含,bundle 缺失时兜底"),但**端内 T4b/灰度选择
|
||||
逻辑在 RewindDesktop 尚未找到实现**,当前双包发出去也只会用 mono。
|
||||
|
||||
### 6.3 mono 下线判据 #3 的建议验收方式
|
||||
|
||||
> **(2026-08-05 MOOT:判据 #3 已随悟空线下线作废,本节验收清单不再
|
||||
> 适用,仅留档。)**
|
||||
|
||||
判据原文:"悟空 bundled skill 分发线(dws_res → bundled-skills,本仓库
|
||||
外)已切 multi"(原 [skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
§5-3;该判据已于 2026-08-05 作废,plan §5 已重新编号)。建议按以下
|
||||
可核查项验收(全绿才算满足):
|
||||
|
||||
1. **发版**:dws-wukong 正式 release 流程(release skill 文档中的
|
||||
`make real-platform` 路径)产出的 dws_res 内含 multi 形态包(bundle 或
|
||||
平铺 zip 集),且 pod 上当前版本即为该形态。
|
||||
2. **构建期消费**:RewindDesktop `develop` 的 `download_binary.py` 把 multi
|
||||
形态同步进 `bundled-skills/`(不再是只认 `dingtalk-workspace.zip`)。
|
||||
3. **运行时消费**:悟空端启动同步后,`~/.real/.skills/bundled/` 下出现
|
||||
`dingtalk-*` 多 skill(而非单个 `dingtalk-workspace`);灰度自更新路径
|
||||
同样支持多 skill 对账。
|
||||
4. **实机回归**:全新安装悟空 → 技能列表出现各 `dingtalk-*` skill 且
|
||||
路由正常;从 mono 旧版升级 → 旧 `dingtalk-workspace` 被清理、无双份
|
||||
派发;灰度通道下发一次 multi 包 → 更新后无残留。
|
||||
5. **回退预案**:悟空侧保留 mono 兜底产物或快速重发能力,直至 DWS 删
|
||||
mono 窗口关闭。
|
||||
|
||||
## 7. 结论
|
||||
|
||||
### 7.1 对齐清单(悟空侧待办,按优先级)
|
||||
|
||||
> **(2026-08-05 MOOT:悟空线下线,本清单整体不再需要执行,仅留档。)**
|
||||
|
||||
| 优先级 | 事项 | 仓库/位置 | 备注 |
|
||||
|---|---|---|---|
|
||||
| P0 | 决策端内 multi 消费方案(选项 A 解 bundle vs 选项 B 平铺 zip) | RewindDesktop + dws-wukong 联合 | 建议 A:与 Qwen Work 已消费形态一致,且 bundle 带版本 manifest |
|
||||
| P0 | `download_binary.py` 支持 multi 形态同步进 bundled-skills | RewindDesktop `scripts/download_binary.py:103,1366-1432` | 选项 A 下识别 `dingtalk-workspace-bundle.zip` |
|
||||
| P0 | 启动同步/技能库支持 bundle 解包与子 skill 注册 | RewindDesktop `tauri-app/src-tauri/src/skills/startup.rs:486-554` | 含旧 mono skill 的迁移清理(现有 `cleanup_removed_bundled_skills` 可复用语义) |
|
||||
| P0 | 灰度自更新支持 multi(单 skill upsert → 多 skill 对账) | RewindDesktop `.../dws_update.rs:440-545` | 否则运行时更新会把 multi 打回 mono |
|
||||
| P1 | 正式发版 target 切 multi(`real-platform` 改打/加打 bundle,或改用 `bundle-platform`/`package-dual`) | dws-wukong `Makefile` | 打包能力已在 develop,缺的是设为默认 + release skill 文档同步更新 |
|
||||
| P1 | 端内灰度选择逻辑落地(双包二选一/T4b,或确认直接全量切) | RewindDesktop(未找到现有实现) | 若选选项 C 过渡则必须 |
|
||||
| P1 | 两套 multi 树的内容同源策略:dws-wukong `dingtalk-skills/`(12 产品)vs DWS `skills/multi/`(19 产品) | dws-wukong + 本仓 | **MOOT(2026-08-05)**:悟空线下线,无需统一;原备注:至少明确"悟空子集"契约与同步 SOP 的归属;7 个缺失产品(dev/event/hrbrain/markdown/pat/profile/skill)是否需要进悟空 |
|
||||
| P2 | `replace-wukong-skill.sh` 等运维脚本支持 bundle 形态 | dws-wukong `scripts/deploy/` | 当前只在特性分支且只处理单 zip |
|
||||
| P2 | 悟空侧公告:端内 `dws skill setup` 行为随上游 embed 变化 | dws-wukong 发版流程 | 对应 §6.1 影响点 1 |
|
||||
| P2 | 判据 #3 验收清单(§6.3)写入 DWS roadmap 并跟踪 | 本仓 `docs/skill-multi-roadmap.md` | 阶段 3 期间启动 |
|
||||
|
||||
### 7.2 待确认问题(本地无法闭环,需找人/仓库确认)
|
||||
|
||||
| # | 问题 | 建议确认方 |
|
||||
|---|---|---|
|
||||
| 1 | pod 线上当前 `dws_res_mac/win` 的版本与内部构成(是否已有人发过双包) | pod.alibaba-inc.com(需内网 SSO)/ 悟空发版 owner |
|
||||
| 2 | "端内 T4b 二选一"灰度逻辑是否已存在(在哪个仓库/平台),还是仅写在 Makefile 注释里的规划 | RewindDesktop 团队 / 悟空端内灰度平台 owner |
|
||||
| 3 | LWP `/r/Adaptor/DwsGrayI/getLatest` 服务端返回的下载 URL 指向何处(pod?另一制品库?),multi 包下发是否需要服务端配合改造 | Adaptor/DwsGrayI 服务端 owner |
|
||||
| 4 | Qwen Work Cloud 六平台打包的发布状态与其对 bundle 的消费方式是否可作为悟空端改造的直接参照 | dws-wukong 仓 owner(merge `9eb801e5` 提交者) |
|
||||
| 5 | 悟空端内是否允许 `dws-shared` 作为独立 bundled skill 暴露(名称/展示/路由策略),还是应内联进各产品 skill | RewindDesktop 技能库 owner |
|
||||
| 6 | dws-wukong `dingtalk-skills/` 与上游 `skills/multi/` 的长期关系:保持派生自本仓 mono,还是改为从上游 multi 同步(**MOOT 2026-08-05**:悟空线下线,无需统一) | dws-wukong + DWS 双侧 owner 联合决策 |
|
||||
| 7 | 悟空线切换的目标时间窗(决定 DWS 阶段 4 判据 #3 的最早可满足点)(**MOOT 2026-08-05**:判据 #3 已作废) | 悟空发版 owner |
|
||||
|
||||
---
|
||||
|
||||
*本文所有"已验证"结论均可按 §1 的仓库路径与文中锚点复查;未能本地验证
|
||||
的项集中在 §7.2。*
|
||||
@@ -2193,8 +2193,8 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2214,8 +2214,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2248,7 +2251,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2291,8 +2294,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2300,17 +2303,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
|
||||
+127
-41
@@ -75,15 +75,14 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
multi 多 skill(默认)—— 按产品拆 N 个独立 skill(dingtalk-*)
|
||||
mono 单 skill(legacy)—— 总入口 SKILL.md + references/products/
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
用 -s/-x 挑选时未列出的已有 dingtalk-* skill 会保留(additive 叠加语义);
|
||||
不带过滤条件的全量安装会清理不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
@@ -168,8 +167,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
|
||||
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
|
||||
// install.js 对齐,清掉不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
filtered := len(includeRaw) > 0 || len(excludeRaw) > 0
|
||||
|
||||
if !autoYes {
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames, filtered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -177,8 +181,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -186,7 +188,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut, filtered)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -251,10 +253,10 @@ func normalizeMultiSkillName(name string) string {
|
||||
// - both lists empty → return `all` (install everything)
|
||||
// - exclude that drops every name → error (avoid silent no-op install)
|
||||
//
|
||||
// The caller is responsible for additive installation: install only the
|
||||
// returned names, leaving any other already-installed dingtalk-* siblings
|
||||
// untouched (handled by installMultiSkillToHomes which does not enumerate
|
||||
// the destination).
|
||||
// The caller threads whether a filter was used into installMultiSkillToHomes:
|
||||
// filtered installs stay additive (already-installed dingtalk-* siblings are
|
||||
// left untouched); a full unfiltered install also removes stale dingtalk-* /
|
||||
// dws-shared directories that are no longer part of the bundle.
|
||||
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||||
if len(include) > 0 && len(exclude) > 0 {
|
||||
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
|
||||
@@ -356,8 +358,8 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
if autoYes || !skillSetupInteractive() {
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||
return skillSetupModeMono, nil
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 multi")
|
||||
return skillSetupModeMulti, nil
|
||||
}
|
||||
|
||||
var choice string
|
||||
@@ -365,10 +367,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("multi = 按产品拆分(默认)\nmono = 单 skill 入口(legacy)").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(默认)", skillSetupModeMulti),
|
||||
huh.NewOption("mono — 单 skill(legacy)", skillSetupModeMono),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -380,8 +382,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
// resolveSkillSetupSource finds the local skill source directory for the
|
||||
// given mode. PR 1 supports only mono; multi is reserved for a later PR
|
||||
// and currently returns an error before reaching this function.
|
||||
// given mode ("mono" or "multi"). Explicit overrides (--source flag or
|
||||
// DWS_SKILL_SOURCE) win and never fall back to another source; without an
|
||||
// override the ordered candidate list (binary-adjacent, working directory,
|
||||
// ~/.dws/skills user cache) is probed for a valid skill root of that mode.
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
@@ -525,16 +529,7 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string, filtered bool) (bool, error) {
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
@@ -549,10 +544,21 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
|
||||
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
|
||||
for _, d := range dests {
|
||||
for _, victim := range mutualExclusionVictims(d, mode) {
|
||||
victims, _ := mutualExclusionVictims(d, mode) // 预览只读,扫描失败不阻塞确认
|
||||
for _, victim := range victims {
|
||||
fmt.Fprintf(out, " × 将删除 %s\n", victim)
|
||||
}
|
||||
}
|
||||
// 全量 multi 安装还会清掉不在 bundle 内的过期 dingtalk-* / dws-shared
|
||||
// (removeStaleMultiSkills);这些删除同样必须先进入确认预览。带
|
||||
// -s/-x 的 filtered 安装是 additive 语义,不会动未列出的 sibling。
|
||||
if mode == skillSetupModeMulti && !filtered {
|
||||
for _, d := range dests {
|
||||
for _, victim := range staleMultiSkillVictims(d, multiSkillNames) {
|
||||
fmt.Fprintf(out, " × 将删除过期 skill %s\n", victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skillSetupInteractive() {
|
||||
return true, nil
|
||||
@@ -580,38 +586,49 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
//
|
||||
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
|
||||
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
|
||||
func mutualExclusionVictims(dest, mode string) []string {
|
||||
//
|
||||
// A scan failure (e.g. unreadable agent home) is returned as a non-nil error
|
||||
// so callers can surface a warning instead of silently skipping cleanup.
|
||||
func mutualExclusionVictims(dest, mode string) ([]string, error) {
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
// dest = <agent-home>/dws → agent-home = parent
|
||||
agentHome := filepath.Dir(dest)
|
||||
entries, err := skillSetupReadDir(agentHome)
|
||||
if err != nil {
|
||||
return nil
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("扫描 multi 残留失败 %s: %w", agentHome, err)
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
||||
if e.IsDir() && (strings.HasPrefix(e.Name(), multiSkillPrefix) || e.Name() == multiSharedSkill) {
|
||||
victims = append(victims, filepath.Join(agentHome, e.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
return victims, nil
|
||||
case skillSetupModeMulti:
|
||||
// dest = <agent-home> → mono 残留是 dest/dws
|
||||
monoPath := filepath.Join(dest, "dws")
|
||||
if _, err := skillSetupStat(monoPath); err == nil {
|
||||
return []string{monoPath}
|
||||
return []string{monoPath}, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
|
||||
// Failures emit a warning to errOut but never abort the install.
|
||||
// Failures — including a failed victim scan — emit a warning to errOut but
|
||||
// never abort the install.
|
||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||
victims, scanErr := mutualExclusionVictims(dest, mode)
|
||||
if scanErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理扫描失败(继续安装) %s: %v\n", dest, scanErr)
|
||||
}
|
||||
for _, victim := range victims {
|
||||
if err := skillSetupRemoveAll(victim); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||
continue
|
||||
@@ -650,7 +667,13 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
//
|
||||
// filtered mirrors whether runSkillSetup saw -s/--skill or -x/--exclude:
|
||||
// a filtered install stays additive and never touches siblings outside the
|
||||
// requested set; a full (unfiltered) install additionally removes stale
|
||||
// dingtalk-* / dws-shared directories that are no longer in the bundle,
|
||||
// matching install.sh / install.ps1 / install.js / upgrade paths.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer, filtered bool) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||
@@ -662,6 +685,10 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
if !filtered {
|
||||
removeStaleMultiSkills(dest, skillNames, out, errOut)
|
||||
}
|
||||
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -682,6 +709,65 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
// staleMultiSkillVictims lists the dingtalk-* / dws-shared directories under
|
||||
// dest that a full (unfiltered) multi install would delete because they are
|
||||
// not part of the bundle. It is the read-only preview companion of
|
||||
// removeStaleMultiSkills; scan failures degrade to a nil list so the
|
||||
// confirmation prompt is never blocked by an unreadable agent home.
|
||||
func staleMultiSkillVictims(dest string, keep []string) []string {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
victims = append(victims, filepath.Join(dest, e.Name()))
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
}
|
||||
|
||||
// removeStaleMultiSkills deletes dingtalk-* / dws-shared directories under
|
||||
// dest that are not part of the current bundle. Best-effort: scan/removal
|
||||
// failures warn on errOut and never abort the install.
|
||||
func removeStaleMultiSkills(dest string, keep []string, out, errOut io.Writer) {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 扫描失败(继续安装) %s: %v\n", dest, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
stale := filepath.Join(dest, e.Name())
|
||||
if err := skillSetupRemoveAll(stale); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 清理失败(继续安装) %s: %v\n", stale, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理过期 skill %s\n", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
|
||||
@@ -86,12 +86,12 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -113,7 +113,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
@@ -143,7 +143,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -202,11 +202,11 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
@@ -231,18 +231,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -36,14 +38,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
if !strings.Contains(buf.String(), "multi") {
|
||||
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +212,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
@@ -254,13 +256,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if vErr != nil {
|
||||
t.Fatalf("mutualExclusionVictims err: %v", vErr)
|
||||
}
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -482,7 +487,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -549,3 +554,114 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
|
||||
// multi install removes stale dingtalk-* / dws-shared directories that are no
|
||||
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
|
||||
// The additive counterpart (filtered install) is covered by
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings.
|
||||
func TestSkillSetupMultiFullInstallCleansStale(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
|
||||
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 1 || skipped != 0 {
|
||||
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Errorf("missing installed skill: %v", err)
|
||||
}
|
||||
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
|
||||
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理过期 skill") {
|
||||
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
|
||||
// surfaces as an errOut warning instead of silently skipping cleanup.
|
||||
func TestSkillSetupMutualExclusionScanWarning(t *testing.T) {
|
||||
oldReadDir := skillSetupReadDir
|
||||
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
|
||||
scanFail := errors.New("scan boom")
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
|
||||
t.Fatal("scan failure should surface as an error")
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
|
||||
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
|
||||
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
|
||||
// full install cleans stale siblings while a filtered install stays additive.
|
||||
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
|
||||
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
|
||||
oldList, oldFilter, oldMulti := skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
|
||||
skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti = oldList, oldFilter, oldMulti
|
||||
})
|
||||
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(t.TempDir(), "dest")}, nil
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
var gotFiltered []bool
|
||||
skillSetupInstallMulti = func(_ string, _ []string, _ []string, _, _ io.Writer, filtered bool) (int, int, error) {
|
||||
gotFiltered = append(gotFiltered, filtered)
|
||||
return 1, 0, nil
|
||||
}
|
||||
|
||||
// Full install (no -s/-x): filtered must be false.
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("full install run err: %v", err)
|
||||
}
|
||||
|
||||
// Filtered install: filtered must be true.
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("filtered install run err: %v", err)
|
||||
}
|
||||
|
||||
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
|
||||
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ var (
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
locateUpgradeSkill = upgrade.LocateSkillsRoot
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
// TestUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
|
||||
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
|
||||
// layout then calling UpgradeSkillLocations with a multi bundle must install
|
||||
// product skills, remove dws/, and leave non-DWS dirs alone.
|
||||
func TestUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := filepath.Join(extract, "multi")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
dir := filepath.Join(multiRoot, 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)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// SwapUserHomeDirForTest swaps the home-dir seam used by UpgradeSkillLocations
|
||||
// and related upgrade path helpers. Restored via t.Cleanup; not safe with
|
||||
// t.Parallel.
|
||||
func SwapUserHomeDirForTest(t *testing.T, fn func() (string, error)) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, fn)
|
||||
}
|
||||
+300
-9
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Permission constants following Unix best practices.
|
||||
@@ -55,6 +57,7 @@ var (
|
||||
upgradeEvalSymlinks = filepath.EvalSymlinks
|
||||
upgradeCopyDir = copyDir
|
||||
upgradeEnsureDir = ensureDir
|
||||
upgradeRemoveAll = os.RemoveAll
|
||||
)
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
@@ -107,21 +110,88 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// UpgradeSkillLocations installs skills from extractedDir into all locations
|
||||
// where they are currently installed or expected.
|
||||
// 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
|
||||
// resolve a release zip usually pass LocateSkillsRoot's result (multi/
|
||||
// preferred when present).
|
||||
//
|
||||
// Package-driven layout (owner decision 2026-08-05 — no disk sticky):
|
||||
// - release zip has multi/ → ALWAYS refresh multi (flat product skills +
|
||||
// dws-shared), removing mono leftover dws/ and stale dingtalk-*/dws-shared.
|
||||
// Existing mono installs are one-shot migrated to multi on upgrade.
|
||||
// - legacy zip with no multi tree → mono refresh path (unchanged fallback)
|
||||
//
|
||||
// This is not a runtime mode-switch product. Fresh install still defaults to
|
||||
// multi with opt-in mono via `dws skill setup --mode mono`; subsequent
|
||||
// upgrades force multi when the package ships it.
|
||||
//
|
||||
// Strategy (matches npm install.js installSkillsToHomes):
|
||||
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
|
||||
// - ~/.agents/skills/ is ALWAYS updated (primary install location)
|
||||
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
|
||||
// directory exists (e.g. ~/.claude/ exists => user has Claude)
|
||||
// - ~/.real/ and other blacklisted paths are NEVER touched
|
||||
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
|
||||
// - If no location was updated at all, fall back to ~/.agents/skills/
|
||||
//
|
||||
// Opposite-mode leftovers are removed so mono and multi never co-exist after
|
||||
// an upgrade. Caches under ~/.dws/skills/{multi,mono} are refreshed
|
||||
// best-effort.
|
||||
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
homeDir, err := upgradeUserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
multiRoot, skills := resolveMultiBundle(extractedDir)
|
||||
if len(skills) > 0 {
|
||||
return upgradeMultiSkillLocations(homeDir, multiRoot, skills)
|
||||
}
|
||||
monoSrc := resolveMonoSkillSrc(extractedDir)
|
||||
if monoSrc != "" {
|
||||
return upgradeMonoSkillLocations(homeDir, monoSrc)
|
||||
}
|
||||
return nil, fmt.Errorf("升级包中找不到可安装的 skill 源")
|
||||
}
|
||||
|
||||
// resolveMultiBundle returns the multi skill root and its skill names when
|
||||
// extractedDir is itself a bundle, or when a multi/ child of the parent
|
||||
// extract root carries one (LocateSkillsRoot already prefers that child).
|
||||
func resolveMultiBundle(extractedDir string) (string, []string) {
|
||||
if skills := bundleSkillNames(extractedDir); len(skills) > 0 {
|
||||
return extractedDir, skills
|
||||
}
|
||||
child := filepath.Join(extractedDir, "multi")
|
||||
if skills := bundleSkillNames(child); len(skills) > 0 {
|
||||
return child, skills
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// resolveMonoSkillSrc finds a mono skill tree for the legacy mono-only
|
||||
// package fallback: the path itself, a sibling mono/ next to a multi root,
|
||||
// or the extract-root SKILL.md copy that release zips still ship.
|
||||
func resolveMonoSkillSrc(extractedDir string) string {
|
||||
if skillTreeHasRoot(extractedDir) {
|
||||
return extractedDir
|
||||
}
|
||||
sibling := filepath.Join(filepath.Dir(extractedDir), "mono")
|
||||
if skillTreeHasRoot(sibling) {
|
||||
return sibling
|
||||
}
|
||||
parent := filepath.Dir(extractedDir)
|
||||
if skillTreeHasRoot(parent) {
|
||||
return parent
|
||||
}
|
||||
child := filepath.Join(extractedDir, "mono")
|
||||
if skillTreeHasRoot(child) {
|
||||
return child
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
|
||||
// per agent home.
|
||||
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
|
||||
result := &SkillUpgradeResult{}
|
||||
|
||||
for i, agentDir := range knownSkillDirs {
|
||||
@@ -140,19 +210,35 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mutual exclusion: installing mono removes multi leftovers. A base
|
||||
// directory that exists but cannot be read fails the home instead of
|
||||
// silently installing mono alongside multi.
|
||||
if err := cleanupMultiLeftovers(filepath.Join(homeDir, agentDir)); err != nil {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
os.RemoveAll(destDir)
|
||||
if err := upgradeCopyDir(extractedDir, destDir); err != nil {
|
||||
if err := upgradeCopyDir(skillSrc, destDir); 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})
|
||||
}
|
||||
|
||||
// Fallback: if nothing succeeded, force the primary location
|
||||
// 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 {
|
||||
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
os.MkdirAll(filepath.Dir(dest), dirPermShared)
|
||||
if err := upgradeCopyDir(extractedDir, dest); err != nil {
|
||||
destBase := filepath.Join(homeDir, ".agents", "skills")
|
||||
os.MkdirAll(destBase, dirPermShared)
|
||||
if err := cleanupMultiLeftovers(destBase); err != nil {
|
||||
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录清理残留也失败: %w", err)
|
||||
}
|
||||
dest := filepath.Join(destBase, "dws")
|
||||
os.RemoveAll(dest)
|
||||
if err := upgradeCopyDir(skillSrc, dest); err != nil {
|
||||
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
|
||||
}
|
||||
// Replace the earlier failed entry for this dir (if any) or append a new one
|
||||
@@ -169,9 +255,214 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: refresh the user-level mono cache so that
|
||||
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
|
||||
// (symmetric with the multi cache refresh in upgradeMultiSkillLocations).
|
||||
refreshSkillCache(homeDir, "mono", skillSrc)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// upgradeMultiSkillLocations installs every skill of the multi bundle into
|
||||
// each agent home as sibling directories and removes the opposite-mode
|
||||
// leftovers. A home is marked failed (and multi is NOT installed into it)
|
||||
// when leftover removal fails, so mono and multi never co-exist.
|
||||
func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*SkillUpgradeResult, error) {
|
||||
skillSet := make(map[string]bool, len(skills))
|
||||
for _, s := range skills {
|
||||
skillSet[s] = true
|
||||
}
|
||||
|
||||
result := &SkillUpgradeResult{}
|
||||
|
||||
for i, agentDir := range knownSkillDirs {
|
||||
destBase := filepath.Join(homeDir, agentDir)
|
||||
|
||||
if isBlacklisted(agentDir) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
parentGate := filepath.Dir(destBase)
|
||||
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := cleanupOppositeModeLeftovers(destBase, skillSet); err != nil {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
failed := false
|
||||
for _, name := range skills {
|
||||
subDest := filepath.Join(destBase, name)
|
||||
os.RemoveAll(subDest)
|
||||
if err := upgradeCopyDir(filepath.Join(multiRoot, name), subDest); err != nil {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
continue
|
||||
}
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
|
||||
}
|
||||
|
||||
// Fallback: if nothing succeeded, force the primary location
|
||||
if len(result.Succeeded()) == 0 {
|
||||
destBase := filepath.Join(homeDir, ".agents", "skills")
|
||||
os.MkdirAll(destBase, dirPermShared)
|
||||
if err := cleanupOppositeModeLeftovers(destBase, skillSet); err != nil {
|
||||
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录清理残留也失败: %w", err)
|
||||
}
|
||||
for _, name := range skills {
|
||||
subDest := filepath.Join(destBase, name)
|
||||
os.RemoveAll(subDest)
|
||||
if err := upgradeCopyDir(filepath.Join(multiRoot, name), subDest); 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
|
||||
// trees, so when the sibling mono/ tree is present the mono cache is
|
||||
// refreshed as well.
|
||||
refreshSkillCache(homeDir, "multi", multiRoot)
|
||||
if monoSrc := filepath.Join(filepath.Dir(multiRoot), "mono"); skillTreeHasRoot(monoSrc) {
|
||||
refreshSkillCache(homeDir, "mono", monoSrc)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// refreshSkillCache best-effort mirrors src into ~/.dws/skills/<name>/ so
|
||||
// that `dws skill setup --mode <name>` can fall back to the cache and stay on
|
||||
// the upgraded version. All errors are swallowed by design.
|
||||
func refreshSkillCache(homeDir, name, src string) {
|
||||
cacheDir := filepath.Join(homeDir, ".dws", "skills", name)
|
||||
os.RemoveAll(cacheDir)
|
||||
if err := os.MkdirAll(filepath.Dir(cacheDir), dirPermShared); err == nil {
|
||||
_ = upgradeCopyDir(src, cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// skillTreeHasRoot reports whether dir carries a top-level SKILL.md.
|
||||
func skillTreeHasRoot(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "SKILL.md"))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// cleanupMultiLeftovers removes every multi-mode skill directory (dingtalk-*
|
||||
// or dws-shared) 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.
|
||||
func cleanupMultiLeftovers(baseDir string) error {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("读取技能目录失败 %s: %w", baseDir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !isMultiSkillDirName(e.Name()) {
|
||||
continue
|
||||
}
|
||||
stale := filepath.Join(baseDir, e.Name())
|
||||
if err := upgradeRemoveAll(stale); err != nil {
|
||||
return fmt.Errorf("清理 multi 残留失败 %s: %w", stale, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOppositeModeLeftovers removes, inside one agent home, the legacy
|
||||
// mono directory (dws/) and every multi skill directory (dingtalk-* or
|
||||
// dws-shared) that is not part of the new bundle. The dingtalk- prefix and
|
||||
// the dws-shared name are reserved for DWS product skills; market-installed
|
||||
// skills do not use them (see skill_command.go).
|
||||
func cleanupOppositeModeLeftovers(destBase string, skillSet map[string]bool) error {
|
||||
if err := upgradeRemoveAll(filepath.Join(destBase, "dws")); err != nil {
|
||||
return fmt.Errorf("清理 mono 残留失败 %s: %w", filepath.Join(destBase, "dws"), err)
|
||||
}
|
||||
entries, err := os.ReadDir(destBase)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !isMultiSkillDirName(e.Name()) || skillSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
stale := filepath.Join(destBase, e.Name())
|
||||
if err := upgradeRemoveAll(stale); err != nil {
|
||||
return fmt.Errorf("清理过期技能失败 %s: %w", stale, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMultiSkillDirName reports whether name belongs to a DWS multi-mode skill
|
||||
// directory (product skills use the dingtalk- prefix; dws-shared is the
|
||||
// mandatory shared bundle).
|
||||
func isMultiSkillDirName(name string) bool {
|
||||
return strings.HasPrefix(name, "dingtalk-") || name == "dws-shared"
|
||||
}
|
||||
|
||||
// bundleSkillNames returns the sorted names of subdirectories of dir that
|
||||
// contain a SKILL.md. It returns nil when dir itself carries a top-level
|
||||
// SKILL.md (mono layout) so callers can distinguish the two layouts.
|
||||
func bundleSkillNames(dir string) []string {
|
||||
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// LocateSkillsRoot resolves the skill root inside an extracted dws-skills.zip,
|
||||
// preferring the multi bundle ({extractDir}/multi) over the legacy mono
|
||||
// layouts handled by LocateSkillMD.
|
||||
func LocateSkillsRoot(extractDir string) string {
|
||||
multiRoot := filepath.Join(extractDir, "multi")
|
||||
if skills := bundleSkillNames(multiRoot); len(skills) > 0 {
|
||||
return multiRoot
|
||||
}
|
||||
return LocateSkillMD(extractDir)
|
||||
}
|
||||
|
||||
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
|
||||
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
|
||||
func LocateSkillMD(extractDir string) string {
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// withFakeHome swaps upgradeUserHomeDir to a temp dir for the test duration.
|
||||
func withFakeHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
|
||||
return home
|
||||
}
|
||||
|
||||
// writeMultiBundle creates a multi-skill bundle root with the given skills.
|
||||
func writeMultiBundle(t *testing.T, root string, skills ...string) string {
|
||||
t.Helper()
|
||||
multi := filepath.Join(root, "multi")
|
||||
for _, name := range skills {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "references", "guide.md"), []byte("guide "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return multi
|
||||
}
|
||||
|
||||
func TestLocateSkillsRootPrefersMulti(t *testing.T) {
|
||||
extract := t.TempDir()
|
||||
// Legacy mono copy at zip root plus the multi bundle: multi must win.
|
||||
os.WriteFile(filepath.Join(extract, "SKILL.md"), []byte("# mono"), 0o644)
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
|
||||
|
||||
got := LocateSkillsRoot(extract)
|
||||
if got != multiRoot {
|
||||
t.Errorf("LocateSkillsRoot() = %q, want multi root %q", got, multiRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateSkillsRootFallsBackToMono(t *testing.T) {
|
||||
extract := t.TempDir()
|
||||
os.WriteFile(filepath.Join(extract, "SKILL.md"), []byte("# mono"), 0o644)
|
||||
|
||||
got := LocateSkillsRoot(extract)
|
||||
if got != extract {
|
||||
t.Errorf("LocateSkillsRoot() = %q, want mono flat root %q", got, extract)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleSkillNamesLayouts(t *testing.T) {
|
||||
// Mono layout (top-level SKILL.md) is not a bundle.
|
||||
mono := t.TempDir()
|
||||
os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644)
|
||||
os.MkdirAll(filepath.Join(mono, "references"), 0o755)
|
||||
if got := bundleSkillNames(mono); got != nil {
|
||||
t.Errorf("mono layout: bundleSkillNames() = %v, want nil", got)
|
||||
}
|
||||
|
||||
// Multi bundle: sorted subdir names containing SKILL.md.
|
||||
extract := t.TempDir()
|
||||
multi := writeMultiBundle(t, extract, "dingtalk-wiki", "dingtalk-chat", "dws-shared")
|
||||
want := []string{"dingtalk-chat", "dingtalk-wiki", "dws-shared"}
|
||||
if got := bundleSkillNames(multi); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("multi layout: bundleSkillNames() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Missing directory.
|
||||
if got := bundleSkillNames(filepath.Join(extract, "nope")); got != nil {
|
||||
t.Errorf("missing dir: bundleSkillNames() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocationsMulti(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
|
||||
// .agents always installs; .claude installs (parent exists); .cursor skipped.
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
claudeBase := filepath.Join(home, ".claude", "skills")
|
||||
for _, base := range []string{agentsBase, claudeBase} {
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-existing state in both homes: mono leftover, a stale dingtalk skill
|
||||
// no longer in the bundle, and a non-DWS dir that must survive.
|
||||
for _, base := range []string{agentsBase, claudeBase} {
|
||||
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
|
||||
os.WriteFile(filepath.Join(base, "dws", "SKILL.md"), []byte("old mono"), 0o644)
|
||||
os.MkdirAll(filepath.Join(base, "dingtalk-old"), 0o755)
|
||||
os.WriteFile(filepath.Join(base, "dingtalk-old", "SKILL.md"), []byte("stale"), 0o644)
|
||||
os.MkdirAll(filepath.Join(base, "other-skill"), 0o755)
|
||||
os.WriteFile(filepath.Join(base, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
|
||||
// The release zip ships both trees; the mono sibling must refresh the
|
||||
// mono cache too, so --mode mono fallbacks stay on the upgraded version.
|
||||
if err := os.MkdirAll(filepath.Join(extract, "mono"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(extract, "mono", "SKILL.md"), []byte("# mono sibling"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
|
||||
for _, base := range []string{agentsBase, claudeBase} {
|
||||
if _, err := os.Stat(filepath.Join(base, "dws")); !os.IsNotExist(err) {
|
||||
t.Errorf("mono leftover still present: %s", filepath.Join(base, "dws"))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "dingtalk-old")); !os.IsNotExist(err) {
|
||||
t.Errorf("stale skill still present: %s", filepath.Join(base, "dingtalk-old"))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(base, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("installed skill missing: %s/%s: %v", base, name, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, name, "references", "guide.md")); err != nil {
|
||||
t.Errorf("installed skill references missing: %s/%s: %v", base, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .cursor has no parent dir: must be skipped and untouched.
|
||||
cursorBase := filepath.Join(home, ".cursor", "skills")
|
||||
if _, err := os.Stat(cursorBase); !os.IsNotExist(err) {
|
||||
t.Errorf(".cursor should not be created, stat err = %v", err)
|
||||
}
|
||||
|
||||
// Succeeded entries report the agent home base in multi mode.
|
||||
succeeded := result.Succeeded()
|
||||
if len(succeeded) != 2 {
|
||||
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
|
||||
}
|
||||
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
|
||||
for _, d := range succeeded {
|
||||
if !wantDirs[d.Dir] {
|
||||
t.Errorf("unexpected succeeded dir %q", d.Dir)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi cache refreshed under the fake home.
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "dingtalk-chat", "SKILL.md")); err != nil {
|
||||
t.Errorf("multi cache not refreshed: %v", err)
|
||||
}
|
||||
// Mono sibling tree refreshed the mono cache too.
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "mono", "SKILL.md")); err != nil {
|
||||
t.Errorf("mono cache not refreshed from sibling mono tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocationsMultiFallbackPrimary(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
// No agent parent dirs at all: only .agents (index 0) is attempted and the
|
||||
// primary fallback must also land there.
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", result.Failed())
|
||||
}
|
||||
primary := filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")
|
||||
if _, err := os.Stat(primary); err != nil {
|
||||
t.Errorf("primary fallback missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocationsMonoOnlyPackageStillWorks(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
mono := t.TempDir()
|
||||
os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644)
|
||||
|
||||
// Legacy mono-only package (no multi/): fall back to mono refresh even
|
||||
// when the disk already has dws/.
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644)
|
||||
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
|
||||
|
||||
result, err := UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", result.Failed())
|
||||
}
|
||||
dest := filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")
|
||||
data, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("mono install missing: %v", err)
|
||||
}
|
||||
if string(data) != "# mono" {
|
||||
t.Errorf("mono content = %q, want refreshed package", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
|
||||
// Mono install refreshes the mono cache so --mode mono fallbacks stay on
|
||||
// the upgraded version.
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "mono", "SKILL.md")); err != nil {
|
||||
t.Errorf("mono cache not refreshed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsMonoDiskMigratesToMulti pins the 2026-08-05
|
||||
// decision: upgrade does NOT stick to disk. A mono-only home is one-shot
|
||||
// migrated to multi when the release zip has multi/ (LocateSkillsRoot input).
|
||||
func TestUpgradeSkillLocationsMonoDiskMigratesToMulti(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644)
|
||||
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
|
||||
if err := os.MkdirAll(filepath.Join(extract, "mono"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(extract, "mono", "SKILL.md"), []byte("# mono sibling"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", result.Failed())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be removed after multi upgrade, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing after mono→multi migration: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "dingtalk-chat", "SKILL.md")); err != nil {
|
||||
t.Errorf("multi cache not refreshed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsEmptyDiskInstallsMulti pins fresh/empty homes:
|
||||
// with a multi package, upgrade installs multi (install default) and never
|
||||
// writes dws/.
|
||||
func TestUpgradeSkillLocationsEmptyDiskInstallsMulti(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", result.Failed())
|
||||
}
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("empty-disk multi install missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Errorf("empty-disk multi upgrade must not create dws/, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsMultiDiskRefreshes pins an already-multi home:
|
||||
// product skills are refreshed, stale dingtalk-* removed, non-DWS kept,
|
||||
// and dws/ stays absent.
|
||||
func TestUpgradeSkillLocationsMultiDiskRefreshes(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
os.MkdirAll(filepath.Join(agentsBase, "dingtalk-chat"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "dingtalk-chat", "SKILL.md"), []byte("OLD chat"), 0o644)
|
||||
os.MkdirAll(filepath.Join(agentsBase, "dingtalk-stale"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "dingtalk-stale", "SKILL.md"), []byte("stale"), 0o644)
|
||||
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
|
||||
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", result.Failed())
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(agentsBase, "dingtalk-chat", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("refreshed chat missing: %v", err)
|
||||
}
|
||||
if string(data) != "# dingtalk-chat" {
|
||||
t.Errorf("chat content = %q, want refreshed package", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Errorf("dws-shared missing after multi refresh: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-stale")); !os.IsNotExist(err) {
|
||||
t.Errorf("stale multi skill should be removed, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Errorf("multi refresh must not create dws/, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsMonoFallbackAfterCopyFailure pins the mono primary
|
||||
// fallback: when the main-loop copy into ~/.agents/skills/dws fails, the
|
||||
// fallback retries the primary location and reports success (legacy
|
||||
// mono-only package path).
|
||||
func TestUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origCopy := upgradeCopyDir
|
||||
copyAttempts := 0
|
||||
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
|
||||
copyAttempts++
|
||||
if copyAttempts == 1 {
|
||||
return errors.New("injected copy failure")
|
||||
}
|
||||
return origCopy(src, dst)
|
||||
})
|
||||
|
||||
mono := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsMonoReadDirErrorFailsHome pins the F4 fix: a base
|
||||
// directory that exists but cannot be read must mark the home failed instead
|
||||
// of silently installing mono alongside the multi leftovers.
|
||||
func TestUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod-based permission injection is unix-only")
|
||||
}
|
||||
home := withFakeHome(t)
|
||||
|
||||
// .agents/skills is unreadable; .claude installs fine so the primary
|
||||
// fallback never runs and the per-home failure is observable as-is.
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(agentsBase, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(agentsBase, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(agentsBase, 0o755) })
|
||||
|
||||
mono := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := UpgradeSkillLocations(mono)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
|
||||
}
|
||||
failed := result.Failed()
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Mono must NOT have been laid down next to the unreadable multi state.
|
||||
// Restore permissions first: stat-ing inside a 000 dir fails with EACCES
|
||||
// regardless of whether the child exists.
|
||||
if err := os.Chmod(agentsBase, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Errorf("mono must not be installed into the unreadable home, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsMultiFallbackCleanupFailure pins the fail-loud
|
||||
// semantics of the multi fallback: when leftover cleanup fails even at the
|
||||
// primary location, UpgradeSkillLocations returns an error instead of
|
||||
// installing multi next to the stale skills.
|
||||
func TestUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
|
||||
home := withFakeHome(t)
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
staleDir := filepath.Join(agentsBase, "dingtalk-stale")
|
||||
if err := os.MkdirAll(staleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleDir, "SKILL.md"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origRemove := upgradeRemoveAll
|
||||
testseam.Swap(t, &upgradeRemoveAll, func(p string) error {
|
||||
if strings.HasSuffix(p, "dingtalk-stale") {
|
||||
return errors.New("injected cleanup failure")
|
||||
}
|
||||
return origRemove(p)
|
||||
})
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
|
||||
|
||||
result, err := UpgradeSkillLocations(multiRoot)
|
||||
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 failed := result.Failed(); len(failed) != 1 {
|
||||
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
|
||||
}
|
||||
// The stale skill was never removed and no partial multi install landed.
|
||||
if _, err := os.Stat(staleDir); err != nil {
|
||||
t.Errorf("stale dir should survive the failed cleanup: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-chat")); !os.IsNotExist(err) {
|
||||
t.Errorf("multi skill must not be installed when cleanup failed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ func TestLocateSkillMD(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocations(t *testing.T) {
|
||||
// Fake HOME: the mono path refreshes ~/.dws/skills/mono best-effort, and
|
||||
// the test must never touch the real user cache.
|
||||
homeDir := withFakeHome(t)
|
||||
|
||||
skillSrc := t.TempDir()
|
||||
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
|
||||
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
|
||||
@@ -74,7 +78,6 @@ func TestUpgradeSkillLocations(t *testing.T) {
|
||||
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
|
||||
found := false
|
||||
|
||||
+136
-14
@@ -13,6 +13,7 @@ set -eu
|
||||
# Environment variables (optional):
|
||||
# DWS_VERSION — release tag (default: latest)
|
||||
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
|
||||
# DWS_SKILL_MODE — mono | multi (default: multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
|
||||
@@ -25,6 +26,11 @@ VERSION="${DWS_VERSION:-latest}"
|
||||
SKILL_NAME="dws"
|
||||
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
|
||||
DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}"
|
||||
SKILL_MODE="$(printf '%s' "${DWS_SKILL_MODE:-multi}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$SKILL_MODE" in
|
||||
mono|multi) ;;
|
||||
*) printf '❌ Invalid DWS_SKILL_MODE=%s. Use mono or multi.\n' "${DWS_SKILL_MODE:-}" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -151,6 +157,103 @@ _copy_skill() {
|
||||
done
|
||||
}
|
||||
|
||||
# multi_tree_has_skills returns 0 only when the given multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi/ tree must never select the multi branch: installing
|
||||
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
|
||||
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
|
||||
multi_tree_has_skills() {
|
||||
_dir="$1"
|
||||
[ -d "$_dir" ] || return 1
|
||||
for _sub in "$_dir"/*/; do
|
||||
if [ -f "${_sub}SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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
|
||||
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"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
_install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"
|
||||
fi
|
||||
}
|
||||
|
||||
_install_multi_to_base() {
|
||||
_msrc="$1"
|
||||
_base="$2"
|
||||
_root="$3"
|
||||
_agent_dir="$4"
|
||||
|
||||
mkdir -p "$_base"
|
||||
|
||||
# Mutual exclusion: remove the mono leftover.
|
||||
rm -rf "$_base/$SKILL_NAME"
|
||||
|
||||
# Remove stale multi skills (dingtalk-* or dws-shared) not in the new bundle.
|
||||
for existing in "$_base"/dingtalk-*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
_name="$(basename "$existing")"
|
||||
if [ ! -f "$_msrc/$_name/SKILL.md" ]; then
|
||||
rm -rf "$existing"
|
||||
fi
|
||||
done
|
||||
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
|
||||
rm -rf "$_base/dws-shared"
|
||||
fi
|
||||
|
||||
_count=0
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
rm -rf "$_dest"
|
||||
mkdir -p "$_dest"
|
||||
cp -R "${skill_dir}." "$_dest/" 2>/dev/null || cp -r "${skill_dir}." "$_dest/"
|
||||
_count=$((_count + 1))
|
||||
done
|
||||
|
||||
if [ "$_root" = "$HOME" ]; then
|
||||
_label="~/$_agent_dir/"
|
||||
else
|
||||
_label="$_root/$_agent_dir/"
|
||||
fi
|
||||
printf ' ✅ Skills → %s (%s product skills)\n' "$_label" "$_count"
|
||||
}
|
||||
|
||||
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
|
||||
install_skills_to_root() {
|
||||
skill_src="$1"
|
||||
@@ -181,6 +284,12 @@ install_skills_to_root() {
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
# Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
rm -rf "$base_dir/dws-shared"
|
||||
for existing in "$base_dir"/dingtalk-*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
rm -rf "$existing"
|
||||
done
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
@@ -236,18 +345,29 @@ main() {
|
||||
SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
printf ' ❌ Skill source not found in release asset\n' >&2
|
||||
exit 1
|
||||
printf '\n'
|
||||
printf ' Installing under root: %s (mode: %s)\n' "$ROOT" "$SKILL_MODE"
|
||||
# Multi first: a release may ship only the multi/ tree without the root
|
||||
# mono copy, so the mono SKILL.md gate must never block a multi install.
|
||||
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
|
||||
# warning — installing it would wipe existing skills and lay down nothing.
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
|
||||
install_multi_skills_to_root "$TMPDIR_WORK/extracted/multi" "$ROOT"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
printf ' ⚠️ Multi skill tree not found or empty in release asset; falling back to mono.\n'
|
||||
fi
|
||||
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
printf ' ❌ Skill source not found in release asset\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
install_skills_to_root "$SKILL_SRC" "$ROOT"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
printf ' Installing under root: %s\n' "$ROOT"
|
||||
install_skills_to_root "$SKILL_SRC" "$ROOT"
|
||||
|
||||
# Cache multi/ (and a mono copy) under ~/.dws/skills so that subsequent
|
||||
# `dws skill setup --mode multi|mono` invocations can find a source.
|
||||
if [ -d "$TMPDIR_WORK/extracted/multi" ]; then
|
||||
# `dws skill setup --mode multi|mono` invocations can find a source. An
|
||||
# empty/corrupt tree must never wipe a previously good cache.
|
||||
if multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
|
||||
cache_dir="${DWS_CACHE_ROOT}/skills/multi"
|
||||
rm -rf "$cache_dir"
|
||||
mkdir -p "$cache_dir"
|
||||
@@ -256,11 +376,13 @@ main() {
|
||||
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
|
||||
printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count"
|
||||
fi
|
||||
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
|
||||
rm -rf "$mono_cache"
|
||||
mkdir -p "$mono_cache"
|
||||
cp -R "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || \
|
||||
cp -r "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || true
|
||||
if [ -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
|
||||
rm -rf "$mono_cache"
|
||||
mkdir -p "$mono_cache"
|
||||
cp -R "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || \
|
||||
cp -r "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
printf ' 📖 Skill includes:\n'
|
||||
|
||||
+153
-61
@@ -17,7 +17,7 @@
|
||||
# DWS_ARCH — architecture override (amd64 or arm64)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
@@ -288,8 +288,8 @@ function Write-Banner {
|
||||
#
|
||||
# Priority (highest first):
|
||||
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
|
||||
# 3. Fallback: mono (non-TTY without env var, e.g. irm | iex)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
|
||||
# 3. Fallback: multi (non-TTY without env var, e.g. irm | iex)
|
||||
function Resolve-SkillMode {
|
||||
if ($env:DWS_SKILL_MODE) {
|
||||
$normalized = $env:DWS_SKILL_MODE.ToLower()
|
||||
@@ -311,38 +311,25 @@ function Resolve-SkillMode {
|
||||
if ($isInteractive) {
|
||||
Write-Host ""
|
||||
Write-Say "Select skill installation mode:"
|
||||
Write-Say " 1) mono — install one bundled dws skill (stable / recommended)"
|
||||
Write-Say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
|
||||
Write-Say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
|
||||
Write-Say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
|
||||
Write-Say " 2) mono — install one bundled dws skill (legacy)"
|
||||
$choice = Read-Host " Choice [1]"
|
||||
switch ($choice) {
|
||||
"" { $script:SkillMode = "mono" }
|
||||
"1" { $script:SkillMode = "mono" }
|
||||
"mono" { $script:SkillMode = "mono" }
|
||||
"2" { $script:SkillMode = "multi" }
|
||||
"" { $script:SkillMode = "multi" }
|
||||
"1" { $script:SkillMode = "multi" }
|
||||
"multi" { $script:SkillMode = "multi" }
|
||||
"2" { $script:SkillMode = "mono" }
|
||||
"mono" { $script:SkillMode = "mono" }
|
||||
default {
|
||||
Write-Say "Unrecognized choice '$choice', defaulting to mono."
|
||||
$script:SkillMode = "mono"
|
||||
Write-Say "Unrecognized choice '$choice', defaulting to multi."
|
||||
$script:SkillMode = "multi"
|
||||
}
|
||||
}
|
||||
Write-Say "Skill mode: $SkillMode"
|
||||
return
|
||||
}
|
||||
|
||||
$script:SkillMode = "mono"
|
||||
}
|
||||
|
||||
function Write-MultiModeNotice {
|
||||
Write-Say ""
|
||||
Write-Say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
|
||||
Write-Say " ⚠ multi is not yet stable. All product-scoped skills pass dispatch verifier,"
|
||||
Write-Say " but interface, naming and cross-skill references may change in future releases."
|
||||
Write-Say " For production / shared environments, use mono mode (--mode mono)."
|
||||
Write-Say ""
|
||||
Write-Say " To install split skills, run:"
|
||||
Write-Say " $BinName skill setup --mode multi"
|
||||
Write-Say " (One skill per product family; requires the dws binary installed above.)"
|
||||
$script:SkillMode = "multi"
|
||||
}
|
||||
|
||||
# ── Install Binary ───────────────────────────────────────────────────────────
|
||||
@@ -427,17 +414,26 @@ function Install-SkillsLocal {
|
||||
$skillSrc = Join-Path (Join-Path $Root "skills") "mono"
|
||||
$multiSrc = Join-Path (Join-Path $Root "skills") "multi"
|
||||
|
||||
if (!(Test-Path $skillSrc)) {
|
||||
Write-Say "⚠️ Local skills directory not found: $skillSrc"
|
||||
Write-Say " Skipping skills installation."
|
||||
return
|
||||
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiSrc)) {
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills (multi) from local source: $multiSrc"
|
||||
Install-MultiSkillsToHomes -MultiSrc $multiSrc -Root $HOME
|
||||
} else {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-Say "⚠️ multi skill tree not found or empty at $multiSrc; falling back to mono."
|
||||
}
|
||||
if (!(Test-Path $skillSrc)) {
|
||||
Write-Say "⚠️ Local skills directory not found: $skillSrc"
|
||||
Write-Say " Skipping skills installation."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from local source: $skillSrc"
|
||||
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
}
|
||||
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from local source: $skillSrc"
|
||||
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
|
||||
if (Test-Path $multiSrc) {
|
||||
Cache-MultiSkills -Source $multiSrc
|
||||
}
|
||||
@@ -450,7 +446,8 @@ function Install-SkillsLocal {
|
||||
function Cache-MultiSkills {
|
||||
param([string]$Source)
|
||||
|
||||
if (!(Test-Path $Source)) { return }
|
||||
# Never let an empty/corrupt multi\ tree wipe a previously good cache.
|
||||
if (!(Test-MultiTreeHasSkills $Source)) { return }
|
||||
|
||||
$cacheDir = Join-Path $HOME ".dws\skills\multi"
|
||||
if (Test-Path $cacheDir) {
|
||||
@@ -464,7 +461,9 @@ function Cache-MultiSkills {
|
||||
function Cache-MonoSkills {
|
||||
param([string]$Source)
|
||||
|
||||
if (!(Test-Path $Source)) { return }
|
||||
# Only refresh when the new bundle actually carries a mono tree — a
|
||||
# multi-only bundle must never wipe a previously good mono cache.
|
||||
if (!(Test-Path (Join-Path $Source "SKILL.md"))) { return }
|
||||
|
||||
$cacheDir = Join-Path $HOME ".dws\skills\mono"
|
||||
if (Test-Path $cacheDir) {
|
||||
@@ -488,6 +487,13 @@ function Install-SkillsToHomes {
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
continue
|
||||
}
|
||||
# Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
$sharedLeftover = Join-Path $baseDir "dws-shared"
|
||||
if (Test-Path $sharedLeftover) {
|
||||
Remove-Item -Path $sharedLeftover -Recurse -Force
|
||||
}
|
||||
Get-ChildItem -Path $baseDir -Directory -Filter "dingtalk-*" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Remove-Item -Path $_.FullName -Recurse -Force }
|
||||
$dest = Join-Path $baseDir $SkillName
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$agentDir\$SkillName"
|
||||
@@ -512,6 +518,93 @@ function Install-SkillsToHomes {
|
||||
}
|
||||
}
|
||||
|
||||
# Test-MultiTreeHasSkills returns $true only when the multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi\ tree must never select the multi branch: installing
|
||||
# it would delete existing dws\ + dingtalk-* skills and lay down nothing.
|
||||
function Test-MultiTreeHasSkills {
|
||||
param([string]$MultiSrc)
|
||||
if (!(Test-Path $MultiSrc)) { return $false }
|
||||
foreach ($dir in Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue) {
|
||||
if (Test-Path (Join-Path $dir.FullName "SKILL.md")) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Install the multi skill bundle (one subdirectory per product skill) into all
|
||||
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
|
||||
# Mutual exclusion: the mono leftover (<home>\dws) and any stale dingtalk-*
|
||||
# skill not present in the new bundle are removed first.
|
||||
function Install-MultiSkillsToHomes {
|
||||
param(
|
||||
[string]$MultiSrc,
|
||||
[string]$Root = $HOME
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
continue
|
||||
}
|
||||
Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir
|
||||
$installed++
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills"
|
||||
}
|
||||
}
|
||||
|
||||
function Install-MultiToBase {
|
||||
param(
|
||||
[string]$MultiSrc,
|
||||
[string]$BaseDir,
|
||||
[string]$Root,
|
||||
[string]$AgentDir
|
||||
)
|
||||
|
||||
if (!(Test-Path $BaseDir)) {
|
||||
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Mutual exclusion: remove the mono leftover.
|
||||
$monoLeftover = Join-Path $BaseDir $SkillName
|
||||
if (Test-Path $monoLeftover) {
|
||||
Remove-Item -Path $monoLeftover -Recurse -Force
|
||||
}
|
||||
|
||||
# Remove stale multi skills (dingtalk-* or dws-shared) not in the new bundle.
|
||||
Get-ChildItem -Path $BaseDir -Directory -Filter "dingtalk-*" -ErrorAction SilentlyContinue |
|
||||
Where-Object { !(Test-Path (Join-Path (Join-Path $MultiSrc $_.Name) "SKILL.md")) } |
|
||||
ForEach-Object { Remove-Item -Path $_.FullName -Recurse -Force }
|
||||
$staleShared = Join-Path $BaseDir "dws-shared"
|
||||
if ((Test-Path $staleShared) -and !(Test-Path (Join-Path (Join-Path $MultiSrc "dws-shared") "SKILL.md"))) {
|
||||
Remove-Item -Path $staleShared -Recurse -Force
|
||||
}
|
||||
|
||||
$count = 0
|
||||
Get-ChildItem -Path $MultiSrc -Directory | ForEach-Object {
|
||||
$name = $_.Name
|
||||
if (!(Test-Path (Join-Path $_.FullName "SKILL.md"))) { return }
|
||||
$dest = Join-Path $BaseDir $name
|
||||
if (Test-Path $dest) {
|
||||
Remove-Item -Path $dest -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $dest -Force | Out-Null
|
||||
Copy-DirRecursive -Source $_.FullName -Destination $dest | Out-Null
|
||||
$count++
|
||||
}
|
||||
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$AgentDir\"
|
||||
} else {
|
||||
$label = Join-Path $Root $AgentDir
|
||||
}
|
||||
Write-Say "✅ Skills → $label ($count product skills)"
|
||||
}
|
||||
|
||||
# ── Install Binary from Source ───────────────────────────────────────────────
|
||||
|
||||
function Install-BinaryFromSource {
|
||||
@@ -580,22 +673,33 @@ function Install-Skills {
|
||||
$skillSrc = Join-Path $extractRoot $SkillName
|
||||
}
|
||||
|
||||
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
|
||||
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
$localRoot = Resolve-SourceRoot
|
||||
if ($localRoot) {
|
||||
Install-SkillsLocal -Root $localRoot
|
||||
# Multi first: a release may ship only the multi\ tree without the
|
||||
# root mono copy, so the mono SKILL.md gate must never block a multi
|
||||
# install. An empty/corrupt multi\ tree (no *\SKILL.md) falls back to
|
||||
# mono with a warning — installing it would wipe existing skills and
|
||||
# lay down nothing.
|
||||
$multiRoot = Join-Path $extractRoot "multi"
|
||||
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiRoot)) {
|
||||
Install-MultiSkillsToHomes -MultiSrc $multiRoot -Root $HOME
|
||||
} else {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-Say "⚠️ multi skill tree not found or empty in release asset; falling back to mono."
|
||||
}
|
||||
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
|
||||
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
$localRoot = Resolve-SourceRoot
|
||||
if ($localRoot) {
|
||||
Install-SkillsLocal -Root $localRoot
|
||||
return
|
||||
}
|
||||
Write-Say "⚠️ No local source found either. Skipping skills installation."
|
||||
return
|
||||
}
|
||||
Write-Say "⚠️ No local source found either. Skipping skills installation."
|
||||
return
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
}
|
||||
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
|
||||
# Cache the multi/ tree (and a mono copy) under ~/.dws/skills so that
|
||||
# subsequent `dws skill setup --mode multi|mono` can find a source.
|
||||
$multiRoot = Join-Path $extractRoot "multi"
|
||||
if (Test-Path $multiRoot) {
|
||||
Cache-MultiSkills -Source $multiRoot
|
||||
}
|
||||
@@ -622,27 +726,15 @@ if (!$NoSkills) {
|
||||
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
|
||||
Install-BinaryFromSource -Root $SourceRoot
|
||||
if (!$NoSkills) {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-SkillsLocal -Root $SourceRoot
|
||||
}
|
||||
Install-SkillsLocal -Root $SourceRoot
|
||||
}
|
||||
} elseif ($SkillsOnly) {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-Skills
|
||||
}
|
||||
Install-Skills
|
||||
} elseif ($NoSkills) {
|
||||
Install-Binary
|
||||
} else {
|
||||
Install-Binary
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-Skills
|
||||
}
|
||||
Install-Skills
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
+168
-66
@@ -14,7 +14,7 @@
|
||||
# DWS_VERSION — version to install (default: latest)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
|
||||
# via the Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
@@ -215,8 +215,8 @@ print_banner() {
|
||||
#
|
||||
# Priority (highest first):
|
||||
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
|
||||
# 3. Fallback: mono (non-TTY without env var, e.g. curl | sh)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
|
||||
# 3. Fallback: multi (non-TTY without env var, e.g. curl | sh)
|
||||
resolve_skill_mode() {
|
||||
if [ -n "${DWS_SKILL_MODE:-}" ]; then
|
||||
raw="$DWS_SKILL_MODE"
|
||||
@@ -237,36 +237,23 @@ resolve_skill_mode() {
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
printf '\n'
|
||||
say "Select skill installation mode:"
|
||||
say " 1) mono — install one bundled dws skill (stable / recommended)"
|
||||
say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
|
||||
say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
|
||||
say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
|
||||
say " 2) mono — install one bundled dws skill (legacy)"
|
||||
printf ' Choice [1]: '
|
||||
read choice || choice=""
|
||||
case "$choice" in
|
||||
""|1|mono) SKILL_MODE="mono" ;;
|
||||
2|multi) SKILL_MODE="multi" ;;
|
||||
""|1|multi) SKILL_MODE="multi" ;;
|
||||
2|mono) SKILL_MODE="mono" ;;
|
||||
*)
|
||||
say "Unrecognized choice '${choice}', defaulting to mono."
|
||||
SKILL_MODE="mono"
|
||||
say "Unrecognized choice '${choice}', defaulting to multi."
|
||||
SKILL_MODE="multi"
|
||||
;;
|
||||
esac
|
||||
say "Skill mode: ${SKILL_MODE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
SKILL_MODE="mono"
|
||||
}
|
||||
|
||||
print_multi_mode_notice() {
|
||||
say ""
|
||||
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
|
||||
say " ⚠ multi is not yet stable. All product-scoped skills pass dispatch verifier,"
|
||||
say " but interface, naming and cross-skill references may change in future releases."
|
||||
say " For production / shared environments, use mono mode (--mode mono)."
|
||||
say ""
|
||||
say " To install split skills, run:"
|
||||
say " ${BIN_NAME} skill setup --mode multi"
|
||||
say " (One skill per product family; requires the dws binary installed above.)"
|
||||
SKILL_MODE="multi"
|
||||
}
|
||||
|
||||
install_binary_from_source() {
|
||||
@@ -301,17 +288,26 @@ install_skills_local() {
|
||||
skill_src="${root}/skills/mono"
|
||||
multi_src="${root}/skills/multi"
|
||||
|
||||
if [ ! -d "$skill_src" ]; then
|
||||
say "⚠️ Local skills directory not found: ${skill_src}"
|
||||
say " Skipping skills installation."
|
||||
return 1
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$multi_src"; then
|
||||
say ""
|
||||
say "📦 Installing agent skills (multi) from local source: ${multi_src}"
|
||||
install_multi_skills_to_homes "$multi_src"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
say "⚠️ Multi skill tree not found or empty at ${multi_src}; falling back to mono."
|
||||
fi
|
||||
if [ ! -d "$skill_src" ]; then
|
||||
say "⚠️ Local skills directory not found: ${skill_src}"
|
||||
say " Skipping skills installation."
|
||||
return 1
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
# Cache multi source for later `dws skill setup --mode multi`.
|
||||
if [ -d "$multi_src" ]; then
|
||||
cache_multi_skills "$multi_src"
|
||||
@@ -331,9 +327,8 @@ cache_multi_skills() {
|
||||
src="$1"
|
||||
cache_dir="${HOME}/.dws/skills/multi"
|
||||
|
||||
if [ ! -d "$src" ]; then
|
||||
return 0
|
||||
fi
|
||||
# Never let an empty/corrupt multi/ tree wipe a previously good cache.
|
||||
multi_tree_has_skills "$src" || return 0
|
||||
|
||||
rm -rf "$cache_dir"
|
||||
mkdir -p "$cache_dir"
|
||||
@@ -354,7 +349,9 @@ cache_mono_skills() {
|
||||
src="$1"
|
||||
cache_dir="${HOME}/.dws/skills/mono"
|
||||
|
||||
if [ ! -d "$src" ]; then
|
||||
# Only refresh when the new bundle actually carries a mono tree — a
|
||||
# multi-only bundle must never wipe a previously good mono cache.
|
||||
if [ ! -f "$src/SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -364,6 +361,8 @@ cache_mono_skills() {
|
||||
}
|
||||
|
||||
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
|
||||
# Installing mono removes multi leftovers (dingtalk-*) for mutual exclusion,
|
||||
# mirroring `dws skill setup --mode mono`.
|
||||
install_skills_to_homes() {
|
||||
skill_src="$1"
|
||||
root="${HOME}"
|
||||
@@ -393,6 +392,12 @@ install_skills_to_homes() {
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
# Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
rm -rf "$base_dir/dws-shared"
|
||||
for existing in "$base_dir"/dingtalk-*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
rm -rf "$existing"
|
||||
done
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
@@ -423,6 +428,105 @@ install_skills_to_homes() {
|
||||
fi
|
||||
}
|
||||
|
||||
# multi_tree_has_skills returns 0 only when the given multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi/ tree must never select the multi branch: installing
|
||||
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
|
||||
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
|
||||
multi_tree_has_skills() {
|
||||
_dir="$1"
|
||||
[ -d "$_dir" ] || return 1
|
||||
for _sub in "$_dir"/*/; do
|
||||
if [ -f "${_sub}SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install the multi skill bundle (one subdirectory per product skill) into all
|
||||
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
|
||||
# Mutual exclusion: the mono leftover (<home>/dws) and any stale dingtalk-*
|
||||
# skill not present in the new bundle are removed first.
|
||||
install_multi_skills_to_homes() {
|
||||
multi_src="$1"
|
||||
root="${HOME}"
|
||||
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"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
_install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"
|
||||
fi
|
||||
}
|
||||
|
||||
_install_multi_to_base() {
|
||||
_msrc="$1"
|
||||
_base="$2"
|
||||
_root="$3"
|
||||
_agent_dir="$4"
|
||||
|
||||
mkdir -p "$_base"
|
||||
|
||||
# Mutual exclusion: remove the mono leftover.
|
||||
rm -rf "$_base/$SKILL_NAME"
|
||||
|
||||
# Remove stale multi skills (dingtalk-* or dws-shared) not in the new bundle.
|
||||
for existing in "$_base"/dingtalk-*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
_name="$(basename "$existing")"
|
||||
if [ ! -f "$_msrc/$_name/SKILL.md" ]; then
|
||||
rm -rf "$existing"
|
||||
fi
|
||||
done
|
||||
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
|
||||
rm -rf "$_base/dws-shared"
|
||||
fi
|
||||
|
||||
_count=0
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
rm -rf "$_dest"
|
||||
mkdir -p "$_dest"
|
||||
cp -R "${skill_dir}." "$_dest/" 2>/dev/null || cp -r "${skill_dir}." "$_dest/"
|
||||
_count=$((_count + 1))
|
||||
done
|
||||
|
||||
case "$_root" in
|
||||
"$HOME") _label="~/$_agent_dir/" ;;
|
||||
*) _label="$_root/$_agent_dir/" ;;
|
||||
esac
|
||||
say "✅ Skills → ${_label} (${_count} product skills)"
|
||||
}
|
||||
|
||||
# One-line summary copy (used for 2nd+ agent targets).
|
||||
_copy_skill_summary() {
|
||||
_src="$1"
|
||||
@@ -598,21 +702,31 @@ install_skills() {
|
||||
elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
|
||||
skill_src="$extract_root/$SKILL_NAME"
|
||||
fi
|
||||
if [ ! -f "$skill_src/SKILL.md" ]; then
|
||||
say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
rm -rf "$tmpdir_skills"
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
return
|
||||
else
|
||||
say "⚠️ No local source checkout found either. Skipping skills installation."
|
||||
return
|
||||
# Multi first: a release may ship only the multi/ tree without the root
|
||||
# mono copy, so the mono SKILL.md gate must never block a multi install.
|
||||
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
|
||||
# warning — installing it would wipe existing skills and lay down nothing.
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$extract_root/multi"; then
|
||||
install_multi_skills_to_homes "$extract_root/multi"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
say "⚠️ Multi skill tree not found or empty in release asset; falling back to mono."
|
||||
fi
|
||||
if [ ! -f "$skill_src/SKILL.md" ]; then
|
||||
say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
rm -rf "$tmpdir_skills"
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
return
|
||||
else
|
||||
say "⚠️ No local source checkout found either. Skipping skills installation."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
install_skills_to_homes "$skill_src"
|
||||
fi
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
# Cache the multi tree (if present in the release asset) so a later
|
||||
# `dws skill setup --mode multi` can find a source without re-downloading.
|
||||
if [ -d "$extract_root/multi" ]; then
|
||||
@@ -647,32 +761,20 @@ main() {
|
||||
if [ -n "$source_root" ]; then
|
||||
install_binary_from_source "$source_root"
|
||||
if [ "$NO_SKILLS" != "1" ]; then
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
else
|
||||
install_skills_local "$source_root"
|
||||
fi
|
||||
install_skills_local "$source_root"
|
||||
fi
|
||||
elif [ "$SKILLS_ONLY" = "1" ]; then
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
else
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
else
|
||||
install_skills
|
||||
fi
|
||||
install_skills
|
||||
fi
|
||||
elif [ "$NO_SKILLS" = "1" ]; then
|
||||
install_binary
|
||||
else
|
||||
install_binary
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
else
|
||||
install_skills
|
||||
fi
|
||||
install_skills
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
# 钉钉 Hrbrain(组织大脑)Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
> **multi 为默认安装模式** — 全部 dingtalk-* skill 已通过 dispatch verifier。单 skill(mono)模式仍可用:`dws skill setup --mode mono`。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
# 钉钉 Markdown 文件 Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
> **multi 为默认安装模式** — 全部 dingtalk-* skill 已通过 dispatch verifier。单 skill(mono)模式仍可用:`dws skill setup --mode mono`。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
# PAT 行为授权 Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
> **multi 为默认安装模式** — 全部 dingtalk-* skill 已通过 dispatch verifier。单 skill(mono)模式仍可用:`dws skill setup --mode mono`。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, Schema discovery, error handling, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
# 钉钉多组织 / profile Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。
|
||||
> **multi 为默认安装模式** — 全部 dingtalk-* skill 已通过 dispatch verifier。单 skill(mono)模式仍可用:`dws skill setup --mode mono`。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
# DWS 技能管理 Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
> **multi 为默认安装模式** — 全部 dingtalk-* skill 已通过 dispatch verifier。单 skill(mono)模式仍可用:`dws skill setup --mode mono`。问题请提 issue 反馈。
|
||||
|
||||
## 前置条件
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* install_js_smoke.mjs — smoke test for build/npm/install.js (npm postinstall).
|
||||
*
|
||||
* Runs the REAL build/npm/install.js against a staged fake package:
|
||||
*
|
||||
* <tmp>/pkg/
|
||||
* install.js (copied from build/npm/install.js)
|
||||
* assets/
|
||||
* dws-<os>-<arch>.tar.gz (dummy archive holding a fake `dws` binary)
|
||||
* dws-skills.zip (tiny release-layout fixture built on the fly,
|
||||
* NOT the real skills/ tree)
|
||||
*
|
||||
* Scenarios (each with an isolated fake HOME):
|
||||
* 1. multi install — dingtalk-* and dws-shared land as sibling
|
||||
* 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
|
||||
* crashing postinstall; a previously good multi
|
||||
* cache is NOT wiped.
|
||||
* 3. 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
|
||||
* staging root is NOT copied into a dws/ dir.
|
||||
*
|
||||
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
|
||||
* install.js itself shells out to). Skips cleanly on win32.
|
||||
*
|
||||
* Usage (standalone; there is intentionally no Go test harness for the npm
|
||||
* installer — test/scripts/install_script_test.go only execs POSIX sh):
|
||||
*
|
||||
* node test/scripts/install_js_smoke.mjs # self-contained, <10s
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
"darwin-x64": "dws-darwin-amd64.tar.gz",
|
||||
"darwin-arm64": "dws-darwin-arm64.tar.gz",
|
||||
"linux-x64": "dws-linux-amd64.tar.gz",
|
||||
"linux-arm64": "dws-linux-arm64.tar.gz",
|
||||
};
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..");
|
||||
const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
|
||||
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
||||
|
||||
if (process.platform === "win32" || !assetName) {
|
||||
console.log(`SKIP: install.js smoke test needs a unix host with tar/zip/unzip (got ${process.platform}-${process.arch})`);
|
||||
process.exit(0);
|
||||
}
|
||||
for (const tool of ["tar", "zip", "unzip"]) {
|
||||
try {
|
||||
childProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" });
|
||||
} catch {
|
||||
console.log(`SKIP: required tool not on PATH: ${tool}`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sh(command, args, options = {}) {
|
||||
childProcess.execFileSync(command, args, { stdio: "ignore", ...options });
|
||||
}
|
||||
|
||||
function writeFile(filePath, content, mode = 0o644) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, { mode });
|
||||
}
|
||||
|
||||
// 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 }.
|
||||
function stagePkg(zipEntries, emptyDirs = []) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-smoke-"));
|
||||
const pkg = path.join(tmp, "pkg");
|
||||
const assets = path.join(pkg, "assets");
|
||||
fs.mkdirSync(assets, { recursive: true });
|
||||
fs.copyFileSync(installJsSource, path.join(pkg, "install.js"));
|
||||
|
||||
const binStage = path.join(tmp, "bin-stage");
|
||||
writeFile(path.join(binStage, "dws"), "#!/bin/sh\necho fake-dws\n", 0o755);
|
||||
sh("tar", ["-czf", path.join(assets, assetName), "-C", binStage, "."]);
|
||||
|
||||
const zipStage = path.join(tmp, "zip-stage");
|
||||
for (const [rel, content] of Object.entries(zipEntries)) {
|
||||
writeFile(path.join(zipStage, rel), content);
|
||||
}
|
||||
for (const dir of emptyDirs) {
|
||||
fs.mkdirSync(path.join(zipStage, dir), { recursive: true });
|
||||
}
|
||||
sh("zip", ["-qr", path.join(assets, "dws-skills.zip"), "."], { cwd: zipStage });
|
||||
|
||||
return { tmp, pkg, home: path.join(tmp, "home") };
|
||||
}
|
||||
|
||||
function runInstall(pkg, home, skillMode) {
|
||||
const env = { ...process.env, HOME: home };
|
||||
if (skillMode !== undefined) {
|
||||
env.DWS_SKILL_MODE = skillMode;
|
||||
} else {
|
||||
delete env.DWS_SKILL_MODE;
|
||||
}
|
||||
return childProcess.spawnSync(process.execPath, [path.join(pkg, "install.js")], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
const scenarios = [];
|
||||
function scenario(name, fn) {
|
||||
scenarios.push([name, fn]);
|
||||
}
|
||||
|
||||
scenario("multi install lays out sibling skills and caches", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"SKILL.md": "# mono root copy\n",
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
"multi/dingtalk-test/references/guide.md": "guide\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
// Pre-existing state the multi install must reconcile.
|
||||
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
||||
writeFile(path.join(home, ".agents", "skills", "dingtalk-stale", "SKILL.md"), "stale\n");
|
||||
writeFile(path.join(home, ".agents", "skills", "other-skill", "SKILL.md"), "not dws\n");
|
||||
|
||||
const res = runInstall(pkg, home, undefined); // default mode = multi
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /Skill mode: multi/);
|
||||
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "SKILL.md")), "dingtalk-test installed");
|
||||
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "references", "guide.md")), "references copied");
|
||||
assert.ok(fs.existsSync(path.join(base, "dws-shared", "SKILL.md")), "dws-shared installed");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono leftover removed");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-stale")), "stale skill removed");
|
||||
assert.ok(fs.existsSync(path.join(base, "other-skill", "SKILL.md")), "non-DWS skill preserved");
|
||||
|
||||
assert.ok(fs.existsSync(path.join(home, ".dws", "skills", "multi", "dingtalk-test", "SKILL.md")), "multi cache filled");
|
||||
assert.equal(fs.readFileSync(path.join(home, ".dws", "skills", "mono", "SKILL.md"), "utf8"), "# mono fixture\n", "mono cache from mono/ tree");
|
||||
assert.ok(fs.existsSync(path.join(pkg, "vendor", "dws")), "binary installed into vendor/");
|
||||
} 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",
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
// Corrupt multi tree: a product subdir without SKILL.md.
|
||||
"multi/dingtalk-broken/references/guide.md": "orphan\n",
|
||||
});
|
||||
try {
|
||||
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
||||
// A previously good multi cache must survive an empty/corrupt bundle.
|
||||
writeFile(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "good cache\n");
|
||||
|
||||
const res = runInstall(pkg, home, "multi");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /falling back to mono/);
|
||||
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "# mono fixture\n", "mono installed from mono/ tree");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-broken")), "broken multi skill not installed");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "utf8"),
|
||||
"good cache\n",
|
||||
"previously good multi cache must not be wiped",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("bogus DWS_SKILL_MODE fails fast with a clear error", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
});
|
||||
try {
|
||||
const res = runInstall(pkg, home, "bogus");
|
||||
assert.notEqual(res.status, 0, "bogus mode must exit non-zero");
|
||||
assert.match(res.stderr, /invalid DWS_SKILL_MODE/);
|
||||
assert.ok(!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-test")), "nothing installed on mode error");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi-only zip in mono mode skips skill install instead of copying staging root", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
// No root SKILL.md and no mono/ tree — a multi-only release layout.
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
const res = runInstall(pkg, home, "mono");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /mono skill tree not found.*skipping skill install/s);
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws")), "staging root must not be copied into dws/");
|
||||
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills", "mono")), "mono cache not refreshed without a mono tree");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, fn] of scenarios) {
|
||||
process.stdout.write(`• ${name} ... `);
|
||||
fn();
|
||||
process.stdout.write("ok\n");
|
||||
}
|
||||
console.log(`OK — ${scenarios.length} install.js smoke scenarios passed`);
|
||||
@@ -45,6 +45,7 @@ func newInstallSourceFixture(t *testing.T) *installSourceFixture {
|
||||
mustWriteFile(t, filepath.Join(root, "cmd", ".keep"), nil, 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "mono", "SKILL.md"), []byte("# Test skill\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "multi", "dingtalk-test", "SKILL.md"), []byte("# Test split skill\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "multi", "dws-shared", "SKILL.md"), []byte("# Test shared skill\n"), 0o644)
|
||||
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
makeStub := `#!/bin/sh
|
||||
@@ -70,13 +71,29 @@ done
|
||||
}
|
||||
|
||||
func (f *installSourceFixture) env(extra ...string) []string {
|
||||
env := append(os.Environ(),
|
||||
return f.envWithSkillMode("mono", extra...)
|
||||
}
|
||||
|
||||
// envWithSkillMode builds the fixture environment. An empty mode omits
|
||||
// DWS_SKILL_MODE entirely so the installer exercises its own default (multi)
|
||||
// resolution; any inherited DWS_SKILL_MODE is filtered out either way.
|
||||
func (f *installSourceFixture) envWithSkillMode(mode string, extra ...string) []string {
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, "DWS_SKILL_MODE=") {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
env = append(env,
|
||||
"HOME="+f.fakeHome,
|
||||
"PATH="+f.stubRoot+":"+os.Getenv("PATH"),
|
||||
"DWS_VERSION=latest",
|
||||
"DWS_SKILLS_ONLY=0",
|
||||
"DWS_SKILL_MODE=mono",
|
||||
)
|
||||
if mode != "" {
|
||||
env = append(env, "DWS_SKILL_MODE="+mode)
|
||||
}
|
||||
return append(env, extra...)
|
||||
}
|
||||
|
||||
@@ -741,6 +758,165 @@ func TestInstallScriptCachesMultiEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedAgentHome pre-creates <fakeHome>/.agents/skills/<name>/SKILL.md with the
|
||||
// given content, simulating a pre-existing skill installation.
|
||||
func seedAgentHome(t *testing.T, fakeHome, name, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md")
|
||||
mustWriteFile(t, p, []byte(content), 0o644)
|
||||
return p
|
||||
}
|
||||
|
||||
func runInstallScript(t *testing.T, scriptPath string, env []string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = env
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// TestInstallScriptSourceModeDefaultMultiInstall exercises the default (no
|
||||
// DWS_SKILL_MODE, non-TTY) path: multi must win, the mono leftover (dws/) and
|
||||
// a stale dingtalk-* skill absent from the bundle must be removed, and
|
||||
// dws-shared must land alongside the product skill.
|
||||
func TestInstallScriptSourceModeDefaultMultiInstall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
seedAgentHome(t, fixture.fakeHome, "dws", "old mono\n")
|
||||
seedAgentHome(t, fixture.fakeHome, "dingtalk-stale", "stale\n")
|
||||
seedAgentHome(t, fixture.fakeHome, "other-skill", "not dws\n")
|
||||
|
||||
output := runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode("",
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
))
|
||||
|
||||
if !strings.Contains(output, "Installing agent skills (multi) from local source") {
|
||||
t.Fatalf("expected multi install branch, output:\n%s", output)
|
||||
}
|
||||
|
||||
base := filepath.Join(fixture.fakeHome, ".agents", "skills")
|
||||
for _, name := range []string{"dingtalk-test", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(base, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill %q not installed: %v\noutput:\n%s", name, err, output)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{"dws", "dingtalk-stale"} {
|
||||
if _, err := os.Stat(filepath.Join(base, gone)); !os.IsNotExist(err) {
|
||||
t.Errorf("%q should be removed by the multi install, stat err=%v\noutput:\n%s", gone, err, output)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS skill must be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallScriptSourceModeEmptyMultiFallsBackToMono pins the empty-bundle
|
||||
// guard: a multi/ tree without any */SKILL.md must fall back to the mono
|
||||
// branch (with a warning) instead of wiping the user's existing skills and
|
||||
// installing nothing. The outcome must equal a normal mono install: dws/ is
|
||||
// replaced with the new mono content and dingtalk-* leftovers are removed by
|
||||
// mono mutual exclusion — the failure mode being guarded against is the
|
||||
// empty-state wipe.
|
||||
func TestInstallScriptSourceModeEmptyMultiFallsBackToMono(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
// Corrupt the multi tree: subdirs exist but no SKILL.md anywhere.
|
||||
if err := os.Remove(filepath.Join(fixture.root, "skills", "multi", "dingtalk-test", "SKILL.md")); err != nil {
|
||||
t.Fatalf("Remove(dingtalk-test/SKILL.md) error = %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(fixture.root, "skills", "multi", "dws-shared")); err != nil {
|
||||
t.Fatalf("RemoveAll(dws-shared) error = %v", err)
|
||||
}
|
||||
|
||||
seedAgentHome(t, fixture.fakeHome, "dws", "old mono\n")
|
||||
seedAgentHome(t, fixture.fakeHome, "dingtalk-keep", "keep\n")
|
||||
seedAgentHome(t, fixture.fakeHome, "other-skill", "not dws\n")
|
||||
|
||||
output := runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode("multi",
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
))
|
||||
|
||||
if !strings.Contains(output, "falling back to mono") {
|
||||
t.Fatalf("expected mono fallback warning, output:\n%s", output)
|
||||
}
|
||||
|
||||
base := filepath.Join(fixture.fakeHome, ".agents", "skills")
|
||||
data, err := os.ReadFile(filepath.Join(base, "dws", "SKILL.md"))
|
||||
if err != nil || !strings.Contains(string(data), "# Test skill") {
|
||||
t.Fatalf("mono dws/ not (re)installed from skills/mono (data=%q, err=%v) — empty multi must not wipe skills\noutput:\n%s", string(data), err, output)
|
||||
}
|
||||
// Mono mutual exclusion legitimately removes dingtalk-* leftovers.
|
||||
if _, err := os.Stat(filepath.Join(base, "dingtalk-keep")); !os.IsNotExist(err) {
|
||||
t.Errorf("dingtalk-keep should be removed by mono mutual exclusion, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "dingtalk-test")); !os.IsNotExist(err) {
|
||||
t.Errorf("dingtalk-test must not be installed from the empty multi tree, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS skill must be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallScriptSourceModeMonoMultiMonoExclusion runs mono → multi → mono
|
||||
// against the same fake HOME and asserts mutual exclusion in both directions,
|
||||
// including the dws-shared shared bundle.
|
||||
func TestInstallScriptSourceModeMonoMultiMonoExclusion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
base := filepath.Join(fixture.fakeHome, ".agents", "skills")
|
||||
|
||||
run := func(mode string) string {
|
||||
t.Helper()
|
||||
return runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode(mode,
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
))
|
||||
}
|
||||
assertPresent := func(rel string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(base, rel, "SKILL.md")); err != nil {
|
||||
t.Errorf("%s/SKILL.md should exist: %v", rel, err)
|
||||
}
|
||||
}
|
||||
assertAbsent := func(rel string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(base, rel)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s should be absent, stat err=%v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
run("mono")
|
||||
assertPresent("dws")
|
||||
assertAbsent("dingtalk-test")
|
||||
assertAbsent("dws-shared")
|
||||
|
||||
out := run("multi")
|
||||
if !strings.Contains(out, "Installing agent skills (multi)") {
|
||||
t.Fatalf("expected multi branch, output:\n%s", out)
|
||||
}
|
||||
assertAbsent("dws")
|
||||
assertPresent("dingtalk-test")
|
||||
assertPresent("dws-shared")
|
||||
|
||||
run("mono")
|
||||
assertPresent("dws")
|
||||
assertAbsent("dingtalk-test")
|
||||
assertAbsent("dws-shared")
|
||||
}
|
||||
|
||||
func writeTarGz(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user