Compare commits

..
Author SHA1 Message Date
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:37 +08:00
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00
261 changed files with 7925 additions and 27181 deletions
+7 -5
View File
@@ -702,11 +702,14 @@ jobs:
with:
go-version-file: go.mod
- name: Test macOS auth and Keychain packages with Race Detection
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
# Full ./internal/app (including schema --all assembly) already runs in the
# race:app shard. Keep this job focused on native auth/keychain paths so
# heavy Schema completeness tests cannot exhaust the 10m budget.
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
- name: Test macOS auth migration and portable auth diagnostics
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
test-windows:
name: Test (Windows)
@@ -817,7 +820,6 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
-65
View File
@@ -6,73 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57-beta.2"
version "1.0.56-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-arm64.tar.gz"
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-arm64.tar.gz"
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
end
def install
+1 -6
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -33,7 +33,6 @@ help:
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -88,7 +87,6 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@@ -128,9 +126,6 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
+12 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+3 -6
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -277,17 +277,14 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
_OA approval workflows: list, approve, reject, revoke, records._
**12 commands**
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+45 -386
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"generated_at": "2026-07-29T00:06:19.285348",
"count": 265,
"results": [
{
"suite": "read",
@@ -485,7 +485,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"availability": "available"
},
{
@@ -658,16 +658,6 @@
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
"command": "+chat-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
@@ -725,7 +715,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"availability": "available"
},
{
@@ -1225,7 +1215,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"availability": "available"
},
{
@@ -1305,7 +1295,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"availability": "available"
},
{
@@ -1325,7 +1315,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"availability": "available"
},
{
@@ -1354,8 +1344,8 @@
"command": "+messages-send-card",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"availability": "available"
},
{
@@ -1425,7 +1415,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"availability": "available"
},
{
@@ -1445,7 +1435,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"availability": "available"
},
{
@@ -1718,454 +1708,123 @@
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-change",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-grant",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-revoke",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-delete",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以 clear 语义移除文档背景色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+checkpoint-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+comment-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+comment-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+comment-reply",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "更新指定评论正文与 mention。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create-from-template",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+doc-append",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+export",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+export-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+export-submit",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+fetch",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+find-doc",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+grant-and-share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+import",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-insert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-preview",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+move",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+review",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+share-doc",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+template-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "doc",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "doc",
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "doc",
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "write",
+272
View File
@@ -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 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
+231
View File
@@ -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(缺了就是现在这副「能装不能管」的样子)。
+146
View File
@@ -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
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
+307
View File
@@ -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。
+153
View File
@@ -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 稳定 |
+290
View File
@@ -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 请求头。
+340
View File
@@ -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。*
+13 -10
View File
@@ -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")
}
-1
View File
@@ -99,7 +99,6 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
-295
View File
@@ -1,295 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
-360
View File
@@ -1,360 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
want := []string{"consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
}
}
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
+3 -13
View File
@@ -27,11 +27,9 @@ import (
"github.com/spf13/cobra"
)
// mountLegacyPublicCommands builds the product + shortcut command tree without
// mutating process-global MCP deps or dynamic server endpoints. Used by the
// Schema source root (declaration-only) path so assembly cannot clobber a live
// runtime's InitDeps caller or plugin endpoints.
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
@@ -53,14 +51,6 @@ func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) [
return mergeTopLevelCommands(commands)
}
// newLegacyPublicCommands is the executable CLI path: inject static MCP
// endpoints, InitDeps, then mount the public command tree.
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
return mountLegacyPublicCommands(runner, loadUserShortcuts)
}
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
+1 -1
View File
@@ -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 {
@@ -29,7 +29,6 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
@@ -38,7 +37,6 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -121,7 +119,6 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -132,7 +129,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -173,7 +169,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -224,7 +220,6 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -257,7 +252,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -297,7 +292,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -308,23 +302,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
+5 -14
View File
@@ -374,21 +374,19 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, true, false)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed Schema surface.
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false, true)
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
@@ -396,10 +394,10 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
return newRootCommandWithEngine(rootCtx, engine, true, false)
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -490,14 +488,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
}
root.AddCommand(utilityCommands...)
if declarationOnly {
// Schema / surface assembly: mount the reviewed tree only. Do not
// injectStaticServers or InitDeps — those mutate process globals and
// would clobber a live runtime's caller and plugin endpoints.
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
} else {
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
}
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
+3 -17
View File
@@ -163,16 +163,9 @@ func commandShort(cmd *cobra.Command) string {
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
// dynamically-registered products — including plugins loaded via
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
// list.
//
// StaticServers are consulted directly (without injectStaticServers) so the
// declaration-only Schema source root can keep reviewed products visible
// without mutating the process-global dynamic endpoint registry.
// SupplementServers stay out of this set: they are helper-only endpoints and
// must not synthesize top-level product visibility.
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
@@ -180,13 +173,6 @@ func resolveVisibleProducts() map[string]bool {
allowed[p] = true
}
}
if fn := edition.Get().StaticServers; fn != nil {
for _, server := range fn() {
if id := strings.TrimSpace(server.ID); id != "" {
allowed[id] = true
}
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
-33
View File
@@ -72,39 +72,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
+14 -41
View File
@@ -413,33 +413,6 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -705,6 +678,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -721,14 +695,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := newServerFailureAPIError(
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -746,15 +720,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
@@ -1,76 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
tests := []struct {
canonical string
optional []string
requireTogether []string
mutuallyExclusive []string
}{
{
canonical: "oa.forecast_process",
optional: []string{"request", "process-code", "dept-id", "form-values"},
requireTogether: []string{"process-code", "dept-id", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
},
{
canonical: "oa.start_process_instance",
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
requireTogether: []string{"process-code", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
tool := tools[test.canonical]
parameters := schemaContractMap(tool["parameters"])
for _, name := range test.optional {
if got := parameters[name]["required"]; got != false {
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
}
}
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
for _, name := range test.mutuallyExclusive {
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
}
constraints, _ := tool["constraints"].(map[string]any)
groups, _ := constraints["mutually_exclusive"].([]any)
if len(groups) != len(test.mutuallyExclusive) {
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
}
for _, rawGroup := range groups {
group, _ := rawGroup.([]any)
if len(group) != 2 {
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
}
}
hasRequestOnlyExample := false
for _, example := range schemaContractStringSlice(tool["examples"]) {
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
hasRequestOnlyExample = true
}
}
if !hasRequestOnlyExample {
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
}
})
}
create := tools["oa.start_process_instance"]
if got := schemaContractString(create["confirmation"]); got != "user_required" {
t.Errorf("create-instance confirmation = %q, want user_required", got)
}
}
+17 -25
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 294
publicShortcutCount = 266
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 295
schemaPublishedShortcutCount = 216
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 294
publiclyDeliveredShortcutCount = 215
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 180; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -194,9 +194,17 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
@@ -226,15 +234,6 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -300,13 +299,6 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
@@ -1,163 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
type schemaSourceRootMarkerCaller struct{}
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
// caller and plugin endpoints.
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
const (
pluginID = "schema-source-root-plugin-marker"
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
)
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
marker := schemaSourceRootMarkerCaller{}
helpers.InitDepsForTest(t, marker)
SetDynamicServers(nil)
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: pluginID,
Endpoint: pluginEndpoint,
CLI: mcptypes.CLIOverlay{
ID: pluginID,
Command: pluginID,
},
})
root := NewSchemaSourceRootCommand()
if root == nil {
t.Fatal("NewSchemaSourceRootCommand returned nil")
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
}
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
}
if resolved.CommandCount() == 0 {
t.Fatal("ResolveSchemaBuild command count is 0")
}
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
registerSchemaRuntimeDelivery()
meta, ok := cli.ResolveMeta("dev app delete")
if !ok || meta.Identity.Canonical == "" {
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
}
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
}
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
// proves the declaration-only Schema source root does not need
// injectStaticServers to keep StaticServers products visible. VisibleProducts
// may be unset; StaticServers alone must still prevent
// hideNonDirectRuntimeCommands from marking those products Hidden — without
// mutating dynamic endpoints.
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
previous := edition.Get()
t.Cleanup(func() { edition.Override(previous) })
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
edition.Override(&edition.Hooks{
Name: "schema-source-root-static-visibility",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
}
},
// VisibleProducts intentionally nil: declaration-only visibility must
// still derive from StaticServers without SetDynamicServers.
})
SetDynamicServers(nil)
root := NewSchemaSourceRootCommand()
var doc *cobra.Command
for _, cmd := range root.Commands() {
if cmd.Name() == "doc" {
doc = cmd
break
}
}
if doc == nil {
t.Fatal("doc command missing from Schema source root")
}
if doc.Hidden {
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
}
// Endpoint resolution may still read StaticServers directly; the invariant
// is that declaration-only construction must not mutate the dynamic registry.
dynamicMu.RLock()
_, injected := dynamicProducts["doc"]
dynamicMu.RUnlock()
if injected {
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
}
}
-105
View File
@@ -1,105 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -1,223 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
+127 -41
View File
@@ -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")
}
+125 -9
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+3 -19
View File
@@ -543,13 +543,6 @@ func TestIsLikelyAMFIKill(t *testing.T) {
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
// successfully re-executes the binary. We use go itself as a stand-in for the
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
//
// GitHub's hosted macOS runners do not reproduce the amfid kill, so in CI this
// test reports a skip that names the unverified path rather than implying the
// self-heal was exercised. Set DWS_REQUIRE_AMFI_SELF_HEAL=1 on a host that does
// enforce amfid to turn such a vacuous run into a hard failure.
const requireAMFISelfHealEnv = "DWS_REQUIRE_AMFI_SELF_HEAL"
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
if runtime.GOOS != "darwin" {
@@ -580,24 +573,15 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
t.Fatalf("strip signature: %v\n%s", err, out)
}
// Sanity: confirm direct exec is killed. When it is not, repairDarwinBinary
// never runs and the rest of this test proves nothing — say so.
// Sanity: confirm direct exec is killed.
if _, err := tryExecVersion(bin); err == nil {
const unverified = "amfid did not kill the unsigned binary, so repairDarwinBinary was NOT exercised"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1)", unverified, requireAMFISelfHealEnv)
}
t.Skipf("%s — host does not enforce amfid (Intel Mac, SIP disabled, or hosted runner)", unverified)
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
}
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
if strings.Contains(err.Error(), "signal: killed") {
const unverified = "host security policy still rejects the ad-hoc signed binary, so the self-heal outcome was NOT verified"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1): %v", unverified, requireAMFISelfHealEnv, err)
}
t.Skipf("%s: %v", unverified, err)
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
}
t.Fatalf("validateNewBinary did not recover: %v", err)
}
+12 -3
View File
@@ -23,11 +23,11 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -117,14 +117,23 @@ type Profile struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
profilectx.Set(profile)
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
return profilectx.Get()
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
@@ -4,7 +4,6 @@
package homology
import (
"context"
"errors"
"fmt"
"io"
@@ -20,22 +19,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// homologyProbeCaller is a throwaway ToolCaller for Execute-based confirmation
// probes. Schema source roots stay declaration-only (no InitDeps); the probe
// path initializes deps locally so leaf RunE wrappers do not nil-deref.
type homologyProbeCaller struct{}
func (homologyProbeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (homologyProbeCaller) Format() string { return "json" }
func (homologyProbeCaller) DryRun() bool { return false }
func (homologyProbeCaller) Fields() string { return "" }
func (homologyProbeCaller) JQ() string { return "" }
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
// executable confirmation gate share one source for every live user_required leaf:
//
@@ -44,12 +29,6 @@ func (homologyProbeCaller) JQ() string { return "" }
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
// Declaration-only Schema roots intentionally skip InitDeps. Probes Execute
// live leaves (including deprecated doc wrappers), so install a local
// throwaway caller without touching SetDynamicServers. Restore the prior
// deps pointer (including nil) — InitDeps(previousCaller) cannot.
helpers.InitDepsForTest(t, homologyProbeCaller{})
root := app.NewSchemaSourceRootCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "")
-12
View File
@@ -158,12 +158,6 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-messages",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +chat-mute",
Aliases: map[string]string{
@@ -342,12 +336,6 @@ var generatedParamAliases = []ParamAliasEntry{
"at-user-ids": "at-users",
},
},
{
CLIPath: "chat +search-msg",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +send-to-group",
Aliases: map[string]string{
+1 -5
View File
@@ -57,8 +57,6 @@
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
@@ -257,9 +255,7 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
]
}
}
-6
View File
@@ -1142,12 +1142,6 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
}
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
// JSON Schema's date-time format means one RFC3339 value. Do not publish
// that narrower wire contract when the CLI also accepts local timestamps
// or date-only values alongside RFC3339.
if strings.Contains(usage, "yyyy-mm-dd") {
return ""
}
return "date-time"
}
if strings.Contains(usage, "a1") {
+18 -9
View File
@@ -30,29 +30,38 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode = contract.ExampleDispositionMode
type AgentExampleMode string
const (
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
type AgentExampleReasonCode string
const (
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
-1
View File
@@ -178,7 +178,6 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
+58
View File
@@ -171,4 +171,62 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"todo task remove-attachment",
},
},
{
ID: "chat-shortcuts-pending-schema-curation",
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
Reviewed: true,
Commands: []string{
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-list",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send-by-bot",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
},
},
}
@@ -123,11 +123,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
}}
return selection
}
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -141,12 +146,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
}}
})
return selection
}
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
@@ -511,17 +511,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"minutes.record_resume --session-id": "Reviewed unpinned adapter: minutes.record_resume has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_start --session-id": "Reviewed unpinned adapter: minutes.record_start has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_stop --session-id": "Reviewed unpinned adapter: minutes.record_stop has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"oa.forecast_process --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.forecast_process --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessForecastPopRequest, so it has no independent top-level MCP property.",
"oa.forecast_process --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers": "Conditional request wrapper: --approvers is transformed into an approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers-action-type": "Conditional request wrapper: --approvers-action-type only configures the generated approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-list": "Conditional request wrapper: --cc-list is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-position": "Conditional request wrapper: --cc-position only configures the generated ccList inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --originator-user-id": "Conditional request wrapper: --originator-user-id is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"pat.batch_grant --domain": "aggregate/two-stage wrapper: --domain is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --domains": "aggregate/two-stage wrapper: --domains is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --product": "aggregate/two-stage wrapper: --product is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
-4
View File
@@ -332,10 +332,6 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
-48
View File
@@ -200,10 +200,6 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -223,54 +219,10 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,14 +75,9 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -91,16 +86,6 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+11 -45
View File
@@ -103,12 +103,11 @@ const ValidationShortcut FlagValidationMode = "shortcut"
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
// helpers can alias to it without touching any call site.
type FlagSpec struct {
Name string // flag name (kebab-case)
Shorthand string // optional one-character Cobra shorthand
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
Name string // flag name (kebab-case)
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
// Required, when true, validates a non-empty effective value in RunE. Plain
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
@@ -552,7 +551,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
flag.Name))
}
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
// Aliases are registered with the main flag's Kind, otherwise an integer
// alias's value would never be readable (silently dropped).
for _, alias := range flag.Aliases {
@@ -573,10 +572,6 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
// instead of silently degrading to 0 / false.
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
registerFlagP(cmd, kind, name, "", def, usage)
}
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
switch kind {
case KindInt:
defInt := 0
@@ -587,7 +582,7 @@ func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usag
}
defInt = v
}
cmd.Flags().IntP(name, shorthand, defInt, usage)
cmd.Flags().Int(name, defInt, usage)
case KindBool:
defBool := false
if def != "" {
@@ -600,15 +595,15 @@ func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usag
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
}
}
cmd.Flags().BoolP(name, shorthand, defBool, usage)
cmd.Flags().Bool(name, defBool, usage)
case KindStringSlice:
var defaults []string
if value := strings.TrimSpace(def); value != "" {
defaults = strings.Split(value, ",")
}
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
cmd.Flags().StringSlice(name, defaults, usage)
default:
cmd.Flags().StringP(name, shorthand, def, usage)
cmd.Flags().String(name, def, usage)
}
}
@@ -890,21 +885,7 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
if err != nil {
return nil, err
}
// Required is checked on the pre-transform string. A transform that
// collapses separator-only input ("," / ";") to nil/empty must still
// fail required flags locally — otherwise BuildArgs would omit the
// key and ConfirmFirst write paths could reach the backend.
if value == nil || emptyTransformResult(value) {
if flag.Required {
message := strings.TrimSpace(flag.RequiredHint)
if message == "" {
message = strings.TrimSpace(flag.RequiredError)
}
if message == "" {
message = fmt.Sprintf("必填参数 --%s 不能为空", flag.Name)
}
return nil, apperrors.NewValidation(message)
}
if value == nil {
continue
}
toolArgs[bind] = value
@@ -915,21 +896,6 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
return toolArgs, nil
}
// emptyTransformResult reports whether a Transform produced an empty payload
// that must not satisfy a Required flag (nil is handled by the caller).
func emptyTransformResult(value any) bool {
switch v := value.(type) {
case string:
return strings.TrimSpace(v) == ""
case []string:
return len(v) == 0
case []any:
return len(v) == 0
default:
return false
}
}
// EffectiveValue reads the value by "explicit main flag → alias → env →
// registration default" order (string form, integers uniformly formatted);
// Trim TrimSpace's the result.
+4 -73
View File
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
cmd := newTestCommand()
RegisterFlags(cmd, []FlagSpec{
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "s", Usage: "S", Default: "d"},
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "req", Usage: "R", MarkRequired: true},
{Name: "hidden", Usage: "H", Hidden: true},
})
@@ -78,11 +78,6 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
t.Fatalf("string flag = %#v", f)
}
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
}
}
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
f := cmd.Flags().Lookup(name)
if f == nil || f.Value.Type() != wantType {
@@ -463,70 +458,6 @@ func TestCrossPlatformCoverageBuildArgsTransformAndErrors(t *testing.T) {
t.Fatalf("nil transform should skip key: %#v", args)
}
// Required + transform-to-empty must fail (separator-only lists)
requiredEmpty := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredHint: "--codes 为必填",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmpty)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmpty); err == nil || !strings.Contains(err.Error(), "--codes 为必填") {
t.Fatalf("required empty transform err = %v", err)
}
requiredEmptySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []string{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptySlice)
_ = cmd.Flags().Set("codes", ";")
if _, err := BuildArgs(cmd, requiredEmptySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-slice transform err = %v", err)
}
requiredEmptyString := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return " ", nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyString)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyString); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-string transform err = %v", err)
}
requiredEmptyAnySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []any{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyAnySlice)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyAnySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty []any transform err = %v", err)
}
requiredErrorHint := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredError: "codes missing after transform",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredErrorHint)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredErrorHint); err == nil || !strings.Contains(err.Error(), "codes missing after transform") {
t.Fatalf("required RequiredError transform err = %v", err)
}
// Non-empty non-list transform result is kept (covers emptyTransformResult default).
keepScalar := []FlagSpec{{
Name: "n", Usage: "N", Required: true, Bind: "count",
Transform: func(string) (any, error) { return 3, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, keepScalar)
_ = cmd.Flags().Set("n", "x")
args, err = BuildArgs(cmd, keepScalar)
if err != nil || args["count"] != 3 {
t.Fatalf("scalar transform args = %#v err = %v", args, err)
}
// Transform error propagates
boom := errors.New("transform boom")
bad := []FlagSpec{{Name: "y", Usage: "Y", Transform: func(string) (any, error) { return nil, boom }}}
-66
View File
@@ -46,9 +46,6 @@ type Error struct {
Message string
Operation string
ServerKey string
Origin string
FailureStage string
ExecutionStarted *bool
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
@@ -58,7 +55,6 @@ type Error struct {
Actions []string
AvailableFlags []string
Snapshot string
Details map[string]any
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
@@ -111,32 +107,6 @@ func WithServerKey(serverKey string) Option {
}
}
// WithOrigin records the component that produced the failure, such as the
// client, MCP gateway, or DingTalk API. It is independent from Category,
// which remains the stable exit-code contract.
func WithOrigin(origin string) Option {
return func(err *Error) {
err.Origin = strings.TrimSpace(origin)
}
}
// WithFailureStage records the execution stage at which the failure occurred.
func WithFailureStage(stage string) Option {
return func(err *Error) {
err.FailureStage = strings.TrimSpace(stage)
}
}
// WithExecutionStarted records whether the downstream business operation was
// known to have started. Unknown state must be represented by omitting this
// option, which is important for safe retry decisions on write operations.
func WithExecutionStarted(started bool) Option {
return func(err *Error) {
value := started
err.ExecutionStarted = &value
}
}
// WithRetryable marks whether the error can be retried safely.
func WithRetryable(retryable bool) Option {
return func(err *Error) {
@@ -216,21 +186,6 @@ func WithSnapshot(path string) Option {
}
}
// WithDetails records an additive machine-readable payload for errors whose
// recovery needs typed context, such as ambiguous target-resolution
// candidates. Callers must keep credentials and other secrets out of details.
func WithDetails(details map[string]any) Option {
return func(err *Error) {
if len(details) == 0 {
return
}
err.Details = make(map[string]any, len(details))
for key, value := range details {
err.Details[key] = value
}
}
}
// WithRPCCode records the original JSON-RPC error code.
func WithRPCCode(code int) Option {
return func(err *Error) {
@@ -340,15 +295,6 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Origin != "" {
errorPayload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
errorPayload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
errorPayload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
@@ -370,9 +316,6 @@ func PrintJSON(w io.Writer, err error) error {
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
if len(typed.Details) > 0 {
errorPayload["details"] = typed.Details
}
if typed.RPCCode != 0 {
errorPayload["rpc_code"] = typed.RPCCode
}
@@ -504,15 +447,6 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if typed.ServerKey != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
}
if typed.Origin != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
}
if typed.FailureStage != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
}
if typed.ExecutionStarted != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
}
if typed.Snapshot != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
}
+10 -38
View File
@@ -20,7 +20,7 @@ import (
"time"
)
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
func TestExitCodeByCategory(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -42,24 +42,17 @@ func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
func TestPrintJSON(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("dws schema doc.create_document", "retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
WithDetails(map[string]any{
"type": "resolution",
"query": "项目群",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
@@ -74,11 +67,6 @@ func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
t.Fatalf("expected reason in output, got %q", got)
}
if !strings.Contains(got, "\"origin\": \"client\"") ||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
!strings.Contains(got, "\"execution_started\": false") {
t.Fatalf("expected failure provenance in output, got %q", got)
}
if !strings.Contains(got, "\"retryable\": true") {
t.Fatalf("expected retryable in output, got %q", got)
}
@@ -88,9 +76,6 @@ func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
t.Fatalf("expected snapshot path in output, got %q", got)
}
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
t.Fatalf("expected structured details in output, got %q", got)
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
@@ -186,7 +171,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
}
}
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -207,7 +192,7 @@ func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
func TestPrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -216,9 +201,6 @@ func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
WithServerKey("calendar"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("retry command"),
@@ -246,19 +228,9 @@ func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in verbose output, got %q", want, got)
}
}
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
if withoutDetails.Details != nil {
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
}
}
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
func TestPrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -282,7 +254,7 @@ func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
func TestPrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -317,7 +289,7 @@ func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -341,7 +313,7 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -363,7 +335,7 @@ func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -384,7 +356,7 @@ func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
}
}
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -69,103 +69,4 @@ func TestCrossPlatformCoverageInterfaceSummaryRemainingEdges(t *testing.T) {
if interfaceIdentifierOnly("含中文") {
t.Fatal("non-ASCII summary classified as identifier-only")
}
if !interfaceIdentifierOnly("search_open_platform-docs.v1") {
t.Fatal("identifier-only summary must be detected")
}
if got := summarizeInterfaceDescription("search_open_platform_docs", 40); got != "" {
t.Fatalf("identifier description = %q, want empty", got)
}
if got := summarizeInterfaceDescription("**查询日历。** 第二句不保留。", 40); got != "查询日历。" {
t.Fatalf("sentence summary = %q", got)
}
if got := summarizeInterfaceDescription("这是一个没有句号而且明显超过允许长度的接口描述文本", 12); got != "这是一个没有句号而..." {
t.Fatalf("truncated summary = %q", got)
}
if got := summarizeInterfaceDescription("First half, then a very long continuation without a terminal mark", 20); !strings.HasSuffix(got, "...") {
t.Fatalf("punctuation-cut summary = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{Source: " mcp-tools ", SourceRevision: "abcdef1234567890"}); got != "mcp-tools@abcdef123456" {
t.Fatalf("interfaceSummarySource = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{}); got != "mcp-interface" {
t.Fatalf("default interfaceSummarySource = %q", got)
}
}
func TestOverallCoverageGapInterfaceMetadataFallbackSuccessPath(t *testing.T) {
if err := applyInterfaceMetadataFallback(&File{}, nil, Options{}, &Stats{}, sourceTracker{}); err != nil {
t.Fatalf("empty InterfaceMetadataPath error = %v", err)
}
if hasSurfaceAgentSummary(File{Tools: map[string]ToolMetadata{
"blank": {AgentSummary: "", agentSummaryPresent: false},
}}, nil, "blank") {
t.Fatal("absent agent summary must not match")
}
if got := summarizeInterfaceDescription("first line\n\nsecond paragraph ignored", 80); got != "first line" {
t.Fatalf("paragraph break summary = %q", got)
}
display := filepath.ToSlash("interface.json")
body := `{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"source_revision": "abcdef1234567890",
"source_hash": "sha256:interface",
"tools": {
"calendar.get": {
"description": "读取指定日历。",
"interface_ref": {"product_id": "calendar", "rpc_name": "get"}
},
"calendar.list": {
"description": "列出当前用户可访问的日历。后续句子不应进入 summary。"
},
"calendar.raw": {"description": "raw_tool_name"},
"outside.tool": {"description": "不在公开命令面"}
}
}`
files := map[string]sourceFile{display: {display: display, data: []byte(body)}}
out := &File{Tools: map[string]ToolMetadata{
"calendar.get": {AgentSummary: "已有摘要", agentSummaryPresent: true},
}}
stats := &Stats{}
if err := applyInterfaceMetadataFallback(out, files, Options{
InterfaceMetadataPath: "interface.json",
ToolPaths: map[string]string{
"calendar.get": "calendar get",
"calendar.list": "calendar list",
"calendar.raw": "calendar raw",
},
}, stats, sourceTracker{}); err != nil {
t.Fatal(err)
}
audit := stats.InterfaceMetadata
if audit == nil || audit.SourceTools != 4 || audit.SurfaceTools != 3 ||
audit.EligibleSummaries != 2 || audit.AppliedSummaries != 1 ||
audit.PreservedSummaries != 1 {
t.Fatalf("interface audit = %#v", audit)
}
if len(audit.RejectedTools) != 1 || audit.RejectedTools[0] != "calendar.raw" ||
len(audit.OutsideSurface) != 1 || audit.OutsideSurface[0] != "outside.tool" {
t.Fatalf("interface audit paths = %#v", audit)
}
list := out.Tools["calendar.list"]
if list.AgentSummary != "列出当前用户可访问的日历。" {
t.Fatalf("applied MCP summary = %q", list.AgentSummary)
}
if list.AgentSummarySource != "mcp-tools-list+cli-registry@abcdef123456" {
t.Fatalf("applied summary source = %q", list.AgentSummarySource)
}
if list.Reviewed == nil || *list.Reviewed {
t.Fatalf("applied reviewed = %#v, want false", list.Reviewed)
}
if list.InterfaceRef != nil {
t.Fatalf("list tool unexpectedly gained interface_ref: %#v", list.InterfaceRef)
}
get := out.Tools["calendar.get"]
if get.InterfaceRef == nil || get.InterfaceRef.ProductID != "calendar" || get.InterfaceRef.RPCName != "get" {
t.Fatalf("preserved tool interface_ref = %#v", get.InterfaceRef)
}
if get.AgentSummary != "已有摘要" {
t.Fatalf("preserved summary overwritten: %#v", get)
}
}
-6
View File
@@ -3,7 +3,6 @@ package helpers
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -79,8 +78,6 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
_ = cmd.Flags().MarkHidden("type")
}
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
@@ -93,9 +90,6 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
@@ -28,13 +28,3 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
}
}
}
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
t.Fatal(err)
}
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
t.Fatal("invalid person type selector unexpectedly succeeded")
}
}
+74 -213
View File
@@ -18,7 +18,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -54,43 +53,6 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
command := hintSubCmd(use, hint)
// Legacy callers may still pass the old command's flags. Let the migration
// command consume them so Cobra reaches RunE and returns the replacement path.
command.DisableFlagParsing = true
return command
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func resolveNativeChatTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if targetresolver.LooksLikeOpenConversationID(raw) {
return raw, nil
}
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
@@ -116,61 +78,6 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
return v
}
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
if len(args) == 1 {
if keyword != "" {
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
}
keyword = strings.TrimSpace(args[0])
}
if keyword == "" {
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
}
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
cmd := &cobra.Command{
Use: "search [query]",
Short: "根据关键词搜索群聊",
Hidden: hidden,
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
Args: cobra.MaximumNArgs(1),
RunE: runChatGroupSearch,
}
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().String("name", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("name")
cmd.Flags().String("group", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("group")
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = cmd.Flags().MarkHidden("size")
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
return cmd
}
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
return err
@@ -404,50 +311,6 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
return text
}
// applyCurrentUserGroupMentions keeps the body placeholders and
// send_personal_message mention arguments aligned for send and reply.
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
var atOpenIDs []string
if rawOpenIDs != "" {
atOpenIDs = strings.Split(rawOpenIDs, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
text = normalizeAtPlaceholders(text, atOpenIDs, true)
if atAll {
params["atAll"] = true
}
if len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
}
return text
}
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
if rawOpenIDs == "" {
return text
}
missing := make([]string, 0)
probeText := text
for _, id := range parseCSVValues(rawOpenIDs) {
placeholder := "<@" + id + ">"
if strings.Contains(probeText, placeholder) {
continue
}
missing = append(missing, placeholder)
probeText += placeholder
}
if len(missing) == 0 {
return text
}
prefix := strings.Join(missing, " ")
if strings.HasPrefix(text, "<@all> ") {
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
}
return prefix + " " + text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
@@ -1414,23 +1277,34 @@ func newChatCommand() *cobra.Command {
},
})
chatSearchCmd := newChatGroupSearchCommand(false)
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
cli.AttachRuntimeSchema(
chatGroupSearchCompatibilityCmd,
"chat",
"search_groups",
"reviewed-compatibility:chat-group-search",
)
cli.AnnotateRuntimeCompatibilityEquivalence(
chatSearchCmd,
chatGroupSearchCompatibilityCmd,
cli.RuntimeCompatibilityEquivalence{
ID: "chat-group-search-compatibility-v1",
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
Reviewed: true,
chatSearchCmd := &cobra.Command{
Use: "search",
Short: "根据关键词搜索群聊",
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
},
)
}
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -2081,15 +1955,29 @@ func newChatCommand() *cobra.Command {
if groupID != "" {
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
var atOpenIds []string
if atOpenIdsStr != "" {
atOpenIds = strings.Split(atOpenIdsStr, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams := map[string]any{
"openConversationId": groupID,
"msgType": "markdown",
"content": string(contentJSON),
"clawType": clawType,
}
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams["content"] = string(contentJSON)
if atAll {
newParams["atAll"] = true
}
if len(atOpenIds) > 0 {
newParams["atOpenDingTalkIds"] = atOpenIds
}
if msgUuid != "" {
newParams["uuid"] = msgUuid
}
@@ -3465,6 +3353,15 @@ func newChatCommand() *cobra.Command {
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatSearchCmd.Flags().MarkHidden("keyword")
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatSearchCmd.Flags().MarkHidden("size")
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
_ = chatGroupMembersCmd.MarkFlagRequired("id")
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
@@ -3490,7 +3387,7 @@ func newChatCommand() *cobra.Command {
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
// message 子命令 flags
@@ -4043,7 +3940,6 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -4051,14 +3947,6 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -5406,14 +5294,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageReplyCmd := &cobra.Command{
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
@@ -5426,6 +5313,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
refSender = resolved
}
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": mustGetFlag(cmd, "text"),
}
contentJSON, _ := marshalJSONRaw(replyContent)
clawType := ""
aiTag, _ := cmd.Flags().GetBool("ai-tag")
if aiTag {
@@ -5434,25 +5328,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"msgType": "reply",
"content": string(contentJSON),
"clawType": clawType,
}
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "text"),
atOpenIDs,
atAll,
)
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": replyText,
}
contentJSON, _ := marshalJSONRaw(replyContent)
toolArgs["content"] = string(contentJSON)
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
@@ -5486,8 +5364,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "conversation-id", Property: "openConversationId"},
},
},
@@ -5502,8 +5378,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageReplyCmd.MarkFlagRequired("text")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
// ── message forward: 转发单条消息 ────────────────────────
@@ -6387,12 +6261,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
if err != nil {
return err
}
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
"openConversationId": groupID,
"openConversationId": mustGetFlag(cmd, "group"),
})
},
}
@@ -6426,7 +6296,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
},
})
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupBotsCmd.MarkFlagRequired("group")
chatGroupMembersRemoveBotCmd := &cobra.Command{
@@ -6562,7 +6432,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Use: "dismiss",
Short: "解散群聊",
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
Example: ` dws chat group dismiss --group <openConversationId>
Example: ` dws chat group dismiss --group <openConversationId> --yes
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -7406,7 +7276,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -7418,14 +7288,6 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -7901,8 +7763,8 @@ status 可选值:
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -8218,11 +8080,10 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
// compatibility hints available through both chat and im.
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
// hint: dws chat send → dws chat message send
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
// hint: dws chat history → dws chat message list
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
@@ -43,75 +43,6 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
return command.RunE(command, nil)
}
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
t.Fatalf("stable native chat target = %q, %v", got, err)
}
t.Run("group search path accepts query", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("group search accepts positional", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("native bots resolves group name", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
{text: `{"result":{"bots":[]}}`},
}}
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d", caller.calls)
}
})
}
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
root := newChatCommand()
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
}
for _, tc := range []struct {
path string
args []string
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if len(remaining) != 0 || command.Name() != tc.path {
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
}
if !command.Hidden || !command.Runnable() {
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
}
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -181,7 +112,6 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
{"category", "delete", "--category-id=1"},
{"category", "rename", "--category-id=1", "--title=renamed"},
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
@@ -15,7 +15,6 @@ package helpers
import (
"context"
"encoding/json"
"io"
"os"
"reflect"
@@ -286,150 +285,6 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
tests := []struct {
name string
args []string
contentField string
wantContent string
wantAtAll bool
wantOpenIDs []string
}{
{
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
},
{
name: "reply",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "reply adds missing member placeholders",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
},
{
name: "reply adds missing member placeholders after at-all",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
},
{
name: "reply at-all preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 联系 @alliance",
wantAtAll: true,
},
{
name: "reply without at flags preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
},
contentField: "content",
wantContent: "联系 @alliance",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
gotAtAll, hasAtAll := args["atAll"]
if tc.wantAtAll {
if !hasAtAll || gotAtAll != true {
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
}
} else if hasAtAll {
t.Fatalf("atAll = %#v; want absent", gotAtAll)
}
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
if len(tc.wantOpenIDs) > 0 {
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
}
} else if hasOpenIDs {
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
}
var content map[string]string
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if got := content[tc.contentField]; got != tc.wantContent {
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+6 -80
View File
@@ -310,44 +310,6 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -429,7 +391,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact user invite/update/update-self: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -452,7 +414,6 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1394,40 +1355,6 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1445,12 +1372,11 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,7 +48,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -99,18 +98,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -152,7 +139,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -188,10 +174,6 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
+28 -37
View File
@@ -98,8 +98,13 @@ func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
}
}
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
if err != nil {
@@ -108,8 +113,10 @@ func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T)
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
if len(caller.readCalls) != 1 ||
caller.readCalls[0].productID != "doc" ||
caller.readCalls[0].toolName != "list_doc_versions" {
t.Fatalf("dry-run read calls = %#v, want doc/list_doc_versions", caller.readCalls)
}
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 7`) {
@@ -117,46 +124,30 @@ func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T)
}
}
func TestDocVersionRevertDryRunDoesNotRejectMissingVersionRemotely(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
func TestDocVersionRevertDryRunRejectsMissingVersionBeforePreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run")
if err != nil {
t.Fatalf("doc version revert dry-run returned error: %v", err)
if err == nil || !strings.Contains(err.Error(), "文档版本 999 不存在") {
t.Fatalf("missing version error = %v, want explicit rejection", err)
}
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "version_not_found" {
t.Fatalf("missing version error = %#v, want typed version_not_found", err)
}
if len(caller.readCalls) != 1 {
t.Fatalf("dry-run read calls = %#v, want one version lookup", caller.readCalls)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 999`) {
t.Fatalf("dry-run output = %q, want preview without remote missing-version rejection", output)
}
}
func TestDocVersionRevertNonDryRunPreflightsVersion(t *testing.T) {
caller := &contractDefectCaller{
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
"doc/revert_doc_version": `{}`,
},
}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-live", "--version", "7", "--yes")
if err != nil {
t.Fatalf("doc version revert returned error: %v", err)
}
// Outside --dry-run, list_doc_versions rides the normal CallTool channel
// (CallReadTool is dry-run-only). Expect preflight then mutation.
if len(caller.calls) != 2 ||
caller.calls[0].productID != "doc" || caller.calls[0].toolName != "list_doc_versions" ||
caller.calls[1].productID != "doc" || caller.calls[1].toolName != "revert_doc_version" {
t.Fatalf("non-dry-run calls = %#v, want list_doc_versions then revert_doc_version", caller.calls)
}
if strings.Contains(output, `"dry_run": true`) {
t.Fatalf("non-dry-run output unexpectedly previewed: %q", output)
if strings.Contains(output, `"tool": "revert_doc_version"`) {
t.Fatalf("missing version emitted a misleading mutation preview: %q", output)
}
}
+1 -3
View File
@@ -2232,9 +2232,7 @@ func splitDevAppList(raw string) []string {
}
// transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform.
// Empty input (including separator-only) returns nil so optional flags omit the
// key from toolArgs. Required flags that collapse to empty are rejected by
// corecmd.BuildArgs after transform.
// Empty input returns nil so the key is omitted from toolArgs.
func transformDevAppListParam(raw string) (any, error) {
values := splitDevAppList(raw)
if len(values) == 0 {
-27
View File
@@ -709,33 +709,6 @@ func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
}
}
func TestCrossPlatformCoverageDevAppEventSubscribeRejectsSeparatorOnlyEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"subscribe_comma", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
{"subscribe_semicolon", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ";", "--yes"}},
{"unsubscribe_comma", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
}
})
}
}
func TestDevAppWebappCommandsBuildParams(t *testing.T) {
cases := []struct {
name string
+20 -82
View File
@@ -816,8 +816,6 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2527,54 +2525,6 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2637,7 +2587,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2651,7 +2601,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -3961,22 +3911,20 @@ CLI 内部自动完成全部流程:
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
if !commandDryRun(cmd) {
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
}
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
}
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
@@ -4277,21 +4225,11 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
return root
}
// printDocDeprecationWarning emits a deprecation warning when shared deps are
// initialized. Schema declaration-only roots skip InitDeps; homology and other
// Execute probes must remain nil-safe on that path.
func printDocDeprecationWarning(msg string) {
if deps == nil || deps.Out == nil {
return
}
deps.Out.PrintWarning(msg)
}
// wrapDocDeprecated wraps a doc command's RunE to print a deprecation warning
// directing users to the corresponding drive command. The original command
// continues to function normally during the transition period.
@@ -4299,7 +4237,7 @@ func wrapDocDeprecated(cmd *cobra.Command, driveSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
printDocDeprecationWarning(fmt.Sprintf(
deps.Out.PrintWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws drive %s' instead.",
c.CommandPath()[8:], // strip "dws doc " prefix
driveSubCmd,
@@ -4315,7 +4253,7 @@ func wrapDocDeprecatedToWiki(cmd *cobra.Command, wikiSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
printDocDeprecationWarning(fmt.Sprintf(
deps.Out.PrintWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
wikiSubCmd,
@@ -4331,7 +4269,7 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
printDocDeprecationWarning(fmt.Sprintf(
deps.Out.PrintWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
targetCmd,
@@ -173,24 +173,6 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
_ = cmd.RunE(cmd, nil)
}
}
// Declaration-only Schema roots leave deps nil; wrappers must not panic.
deps = nil
for _, wrap := range []func(*cobra.Command){
func(cmd *cobra.Command) { wrapDocDeprecated(cmd, "drive target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToWiki(cmd, "wiki target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToTarget(cmd, "target") },
} {
cmd := &cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}
wrap(cmd)
root := &cobra.Command{Use: "dws"}
doc := &cobra.Command{Use: "doc"}
root.AddCommand(doc)
doc.AddCommand(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("nil-deps deprecation wrapper: %v", err)
}
}
}
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
-24
View File
@@ -1,24 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "github.com/spf13/cobra"
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
// to the Shortcut application layer. The Cobra leaf still owns its own flags
// and Contract; this bridge only shares the raw/API execution primitive.
func RunDocImportShortcut(cmd *cobra.Command) error {
return runImportCommand(cmd, nil, docImportFlowConfig())
}
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
// insertion implementation with the canonical Doc Shortcut.
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
return runMediaInsert(cmd, nil)
}
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
return runDocStyleCoverSet(cmd, nil)
}
-284
View File
@@ -1,284 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
whiteboardDefaultHeight = 600
)
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
// 继续调用 whiteboard query/update。
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
var (
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
whiteboardSleep = time.Sleep
whiteboardJSONMarshal = json.Marshal
prepareWhiteboardCard = prepareJsonMLNode
)
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
node := []any{
"card",
map[string]any{
"uuid": blockUUID,
"cardType": "hetu",
"height": whiteboardDefaultHeight,
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
},
[]any{"span", map[string]any{"data-type": "text"},
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
}
out, err := whiteboardJSONMarshal(node)
if err != nil {
return ""
}
return string(out)
}
func extractWhiteboardID(attrs map[string]any) string {
meta, _ := attrs["metadata"].(map[string]any)
if meta == nil {
return ""
}
id, _ := meta["id"].(string)
return id
}
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID,
"blockId": blockID,
"format": "jsonml",
})
if err != nil {
return nil, err
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
blocksField, ok := data["blocks"]
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
}
blocks, ok := blocksField.([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
}
var raw string
for _, block := range blocks {
entry, _ := block.(map[string]any)
if entry == nil || entry["blockId"] != blockID {
continue
}
raw, _ = entry["jsonml"].(string)
break
}
if raw == "" {
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
}
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
return nil, fmt.Errorf("parse block jsonml: %w", err)
}
return node, nil
}
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
if err != nil {
return nil, err
}
if len(node) < 2 {
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
}
attrs, _ := node[1].(map[string]any)
if attrs == nil {
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
}
return attrs, nil
}
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
blockUUID := uuid.New().String()
whiteboardID := uuid.New().String()
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
normalized, err := prepareWhiteboardCard(cmd, element)
if err != nil {
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
}
toolArgs := map[string]any{
"nodeId": nodeID,
"jsonml": normalized,
"format": "jsonml",
}
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
toolArgs["referenceBlockId"] = v
where, _ := cmd.Flags().GetString("where")
if where == "" {
where = "after"
}
toolArgs["where"] = where
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
toolArgs["referenceBlockId"] = v
}
if cmd.Flags().Changed("index") {
index, _ := cmd.Flags().GetInt("index")
toolArgs["index"] = index
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
ctx := cmd.Context()
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
return err
}
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
persistedID := ""
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
switch {
case queryErr == nil:
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
persistedID = extractWhiteboardID(attrs)
case errors.Is(queryErr, errWhiteboardBlockPending):
// 块暂不可见,属于最终一致性,继续重试。
default:
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
return fmt.Errorf(
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
blockUUID, queryErr)
}
if persistedID != "" {
break
}
if attempt < len(whiteboardRetryDelays) {
whiteboardSleep(whiteboardRetryDelays[attempt])
}
}
result := map[string]any{"blockId": blockUUID}
if persistedID == "" {
result["whiteboardId"] = nil
deps.Out.PrintWarning(fmt.Sprintf(
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
nodeID, blockUUID))
} else {
result["whiteboardId"] = persistedID
}
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
insertCmd := &cobra.Command{
Use: "insert",
Short: "插入白板卡片",
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
不能同时使用。`,
Example: ` dws doc whiteboard insert --node DOC_ID
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
RunE: runWhiteboardInsert,
}
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
insertCmd.Flags().String(name, "", "--node 的兼容别名")
_ = insertCmd.Flags().MarkHidden(name)
}
DeclareLeafMetadata(insertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "whiteboard_insert",
CanonicalPath: "doc.whiteboard_insert",
CLIPath: "doc whiteboard insert",
PrimaryCLIPath: "doc whiteboard insert",
},
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
},
},
})
root.AddCommand(insertCmd)
return root
}
+1 -3
View File
@@ -66,8 +66,6 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -631,7 +629,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if driveWorkerContextErr(runCtx) != nil {
if runCtx.Err() != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+40 -26
View File
@@ -13,8 +13,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2870,32 +2868,48 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
var checks atomic.Int32
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
checks.Add(1)
return context.Canceled
})
var requests atomic.Int32
testseam.Swap(t, &driveRangeClient, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
t.Fatalf("downloadRangedParts context guard: %v", err)
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// 每次请求加小延迟,让总处理时间接近 deadline
time.Sleep(50 * time.Microsecond)
var start, end int64
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
}
if end >= int64(len(content)) {
end = int64(len(content)) - 1
}
resp := &http.Response{
StatusCode: http.StatusPartialContent,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
}
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
return resp, nil
}),
}
if checks.Load() != 1 {
t.Fatalf("worker context checks = %d, want 1", checks.Load())
}
if requests.Load() != 0 {
t.Fatalf("worker requests = %d, want 0", requests.Load())
// 多次尝试以确保覆盖(goroutine 调度非确定性)
for attempt := 0; attempt < 50; attempt++ {
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
cancel()
}
}
-34
View File
@@ -1,34 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Test helpers for package-level deps. Production code must not call these;
// the ForTest suffix is the boundary.
// InitDepsForTest installs caller for the test duration and restores the prior
// deps pointer (including a prior nil) via testseam. Prefer this over
// InitDeps + InitDeps(previousCaller): restoring through InitDeps(nil) leaves a
// non-nil Deps with a nil Caller, which is not the original unset state.
func InitDepsForTest(t *testing.T, caller edition.ToolCaller) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
}
-38
View File
@@ -1,38 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type fortestCoverageCaller struct{}
func (fortestCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (fortestCoverageCaller) Format() string { return "json" }
func (fortestCoverageCaller) DryRun() bool { return false }
func (fortestCoverageCaller) Fields() string { return "" }
func (fortestCoverageCaller) JQ() string { return "" }
// TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps covers the
// ForTest deps restore helper used by declaration-only Schema probes.
func TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps(t *testing.T) {
testseam.Protect(t, &deps)
deps = nil
marker := fortestCoverageCaller{}
InitDepsForTest(t, marker)
if got := GetCaller(); got != marker {
t.Fatalf("GetCaller() during InitDepsForTest = %T, want marker", got)
}
if deps == nil {
t.Fatal("InitDepsForTest left deps nil before test cleanup")
}
}
-1
View File
@@ -64,7 +64,6 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"enterprise_person_search": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
-3
View File
@@ -190,9 +190,6 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
if toolName == "enterprise_person_search" {
return true
}
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
+4 -11
View File
@@ -229,15 +229,6 @@ func TestLeafArgs(t *testing.T) {
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
// Satisfy Required flags first: BuildArgs now rejects Required transforms that
// collapse to empty (unset CSV), matching runtime behavior after ValidateRequired.
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
args, err := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
@@ -248,8 +239,10 @@ func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
if _, present := args["cursor"]; present {
t.Fatalf("cursor present = %v, want omitted when zero", args["cursor"])
}
if v, present := args["accessToken"]; !present || v != "tok" {
t.Fatalf("accessToken = %v/%v, want env tok", v, present)
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
// leafArgs 之前执行,保证真实路径不会发出空值)。
if v, present := args["accessToken"]; !present || v != "" {
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
}
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
if v, present := args["remindType"]; !present || v != "app" {
+1 -267
View File
@@ -1,55 +1,21 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
var request map[string]any
if err := dec.Decode(&request); err != nil || request == nil {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("JSON 请求不能为 null")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("JSON 请求包含多余内容")
}
return request, nil
}
func oaFormValues(raw string) ([]map[string]string, error) {
var values map[string]string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
result := make([]map[string]string, 0, len(values))
for name, value := range values {
result = append(result, map[string]string{"name": name, "value": value})
}
return result, nil
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
// approve_processInstance, reject_processInstance, revoke_processInstance,
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance
// get_inst_revert_activities
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -1051,195 +1017,6 @@ func newOaCommand() *cobra.Command {
},
}
approvalFormSchemaCmd := &cobra.Command{
Use: "form-schema", Short: "查询审批模板的表单 Schema",
Example: "dws oa approval form-schema --process-code <processCode>",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "process-code"); err != nil {
return err
}
return callMCPTool("get_process_schema", map[string]any{"processCode": mustGetFlag(cmd, "process-code")})
},
}
DeclareLeafMetadata(approvalFormSchemaCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "get_process_schema",
CanonicalPath: "oa.get_process_schema",
CLIPath: "oa approval form-schema",
PrimaryCLIPath: "oa approval form-schema",
},
Description: "查询审批模板的表单 Schema",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_schema"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询审批模板的表单 Schema",
UseWhen: []string{"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"},
AvoidWhen: []string{"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"},
Examples: []string{"dws oa approval form-schema --process-code <processCode>"},
},
Parameters: []contract.ParamDecl{
{Name: "process-code", Property: "processCode"},
},
},
})
approvalForecastCmd := &cobra.Command{
Use: "forecast-process", Short: "根据表单值预测审批流程与自选节点",
Example: "dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
RunE: func(cmd *cobra.Command, args []string) error {
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
request, err := decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": request})
}
if err := validateRequiredFlags(cmd, "process-code", "dept-id", "form-values"); err != nil {
return err
}
deptID, err := strconv.ParseInt(mustGetFlag(cmd, "dept-id"), 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "deptId": deptID, "formComponentValues": [][]map[string]string{values}}})
},
}
DeclareLeafMetadata(approvalForecastCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "forecast_process",
CanonicalPath: "oa.forecast_process",
CLIPath: "oa approval forecast-process",
PrimaryCLIPath: "oa approval forecast-process",
},
Description: "根据表单值预测审批流程与自选节点",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "forecast_process"},
},
Selection: contract.SelectionSpec{
AgentSummary: "预测审批流程与自选审批节点",
UseWhen: []string{"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"},
AvoidWhen: []string{"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"},
Examples: []string{
"dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessForecastPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessForecastPopRequest", InterfaceType: "object"},
},
},
})
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
RunE: func(cmd *cobra.Command, args []string) error {
if !commandDryRun(cmd) {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
return fmt.Errorf("发起审批实例会创建真实业务数据;请先核对参数,然后添加 --yes 确认执行")
}
}
var request map[string]any
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
var err error
request, err = decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
} else {
if err := validateRequiredFlags(cmd, "process-code", "form-values"); err != nil {
return err
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
request = map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "formComponentValues": values}
if dept, _ := cmd.Flags().GetString("dept-id"); dept != "" {
value, err := strconv.ParseInt(dept, 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
request["deptId"] = value
}
if userID, _ := cmd.Flags().GetString("originator-user-id"); userID != "" {
request["originatorUserId"] = userID
}
if rawApprovers, _ := cmd.Flags().GetString("approvers"); rawApprovers != "" {
action, _ := cmd.Flags().GetString("approvers-action-type")
if action != "AND" && action != "OR" && action != "NONE" {
return fmt.Errorf("--approvers-action-type 必须为 AND、OR 或 NONE")
}
request["approvers"] = []map[string]any{{"actionType": action, "userIds": strings.Split(rawApprovers, ",")}}
}
if rawCC, _ := cmd.Flags().GetString("cc-list"); rawCC != "" {
position, _ := cmd.Flags().GetString("cc-position")
if position != "START" && position != "FINISH" && position != "START_FINISH" {
return fmt.Errorf("--cc-position 必须为 START、FINISH 或 START_FINISH")
}
request["ccList"] = strings.Split(rawCC, ",")
request["ccPosition"] = position
}
}
return callMCPTool("start_process_instance", map[string]any{"ProcessInstanceCreationPopRequest": request})
},
}
DeclareLeafMetadata(approvalCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "start_process_instance",
CanonicalPath: "oa.start_process_instance",
CLIPath: "oa approval create-instance",
PrimaryCLIPath: "oa approval create-instance",
},
Description: "发起审批实例(需要 --yes 确认)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "start_process_instance"},
},
Selection: contract.SelectionSpec{
AgentSummary: "发起新的审批实例",
UseWhen: []string{"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"},
AvoidWhen: []string{"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"},
Examples: []string{
"dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceCreationPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessInstanceCreationPopRequest", InterfaceType: "object"},
},
},
})
approvalListPendingCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
approvalListPendingCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
approvalListPendingCmd.Flags().String("page", "", "分页页码 (可选)")
@@ -1310,46 +1087,6 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd.Flags().String("target-activity-id", "", "退回到的节点 ID(退回发起人固定传 sid-startevent)(必填)")
approvalRevertTaskCmd.Flags().String("action", "", "退回方式:REVERT_FOR_APPROVAL(退回到审批人)/ REVERT_FOR_RESUBMIT(退回到发起人)(必填)")
approvalRevertTaskCmd.Flags().String("remark", "", "退回说明 (可选)")
approvalFormSchemaCmd.Flags().String("process-code", "", "审批模板 processCode (必填)")
approvalForecastCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("dept-id", "", "发起人部门 ID(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalForecastCmd.MarkFlagsOneRequired("request", "process-code")
approvalForecastCmd.MarkFlagsRequiredTogether("process-code", "dept-id", "form-values")
forecastMutuallyExclusive := make([][]string, 0, 3)
for _, name := range []string{"process-code", "dept-id", "form-values"} {
approvalForecastCmd.MarkFlagsMutuallyExclusive("request", name)
forecastMutuallyExclusive = append(forecastMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalForecastCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: forecastMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalCreateCmd.Flags().String("originator-user-id", "", "审批发起人 userId")
approvalCreateCmd.Flags().String("approvers", "", "审批人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("approvers-action-type", "OR", "审批类型:AND、OR 或 NONE")
approvalCreateCmd.Flags().String("cc-list", "", "抄送人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("cc-position", "START", "抄送时点:START、FINISH 或 START_FINISH")
approvalCreateCmd.MarkFlagsOneRequired("request", "process-code")
approvalCreateCmd.MarkFlagsRequiredTogether("process-code", "form-values")
createSimpleFlags := []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"}
createMutuallyExclusive := make([][]string, 0, len(createSimpleFlags))
for _, name := range createSimpleFlags {
approvalCreateCmd.MarkFlagsMutuallyExclusive("request", name)
createMutuallyExclusive = append(createMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalCreateCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: createMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "form-values"}},
})
approvalCmd.AddCommand(
approvalListPendingCmd,
@@ -1372,9 +1109,6 @@ func newOaCommand() *cobra.Command {
approvalAppendTaskCmd,
approvalRevertActivitiesCmd,
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalCreateCmd,
)
root.AddCommand(approvalCmd)
+1 -169
View File
@@ -1,34 +1,6 @@
package helpers
import (
"io"
"os"
"strings"
"testing"
)
func executeOACommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
previous := deps
previousArgs := os.Args
os.Args = []string{"dws", "oa"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = previous
os.Args = previousArgs
})
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
import "testing"
func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
@@ -59,143 +31,3 @@ func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
t.Fatalf("revert task: %v", err)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceMapsInternalSimpleOptions(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--process-code", "PROC",
"--form-values", `{"事由":"测试"}`,
"--originator-user-id", "originator",
"--approvers", "approver-1,approver-2",
"--approvers-action-type", "AND",
"--cc-list", "cc-1,cc-2",
"--cc-position", "FINISH",
"--yes",
)
if err != nil {
t.Fatalf("create instance: %v", err)
}
if caller.server != "oa" || caller.tool != "start_process_instance" {
t.Fatalf("called %s/%s, want oa/start_process_instance", caller.server, caller.tool)
}
request, ok := caller.args["ProcessInstanceCreationPopRequest"].(map[string]any)
if !ok {
t.Fatalf("request payload = %#v", caller.args)
}
if got := request["originatorUserId"]; got != "originator" {
t.Fatalf("originatorUserId = %#v", got)
}
approvers, ok := request["approvers"].([]map[string]any)
if !ok || len(approvers) != 1 || approvers[0]["actionType"] != "AND" {
t.Fatalf("approvers = %#v", request["approvers"])
}
if got := approvers[0]["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "approver-1" || got.([]string)[1] != "approver-2" {
t.Fatalf("approver userIds = %#v", got)
}
if got := request["ccList"]; len(got.([]string)) != 2 || got.([]string)[0] != "cc-1" || got.([]string)[1] != "cc-2" {
t.Fatalf("ccList = %#v", got)
}
if got := request["ccPosition"]; got != "FINISH" {
t.Fatalf("ccPosition = %#v", got)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRejectsMixedRequestModes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
"--process-code", "PROC",
"--yes",
)
if err == nil {
t.Fatal("mixed request modes returned nil")
}
if caller.calls != 0 {
t.Fatalf("unexpected MCP call count: %d", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
)
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("create instance without --yes error = %v, want explicit --yes requirement", err)
}
if caller.calls != 0 {
t.Fatalf("create instance without --yes made %d MCP calls", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
validCases := []struct {
name string
args []string
tool string
}{
{
name: "form schema",
args: []string{"approval", "form-schema", "--process-code", "PROC"},
tool: "get_process_schema",
},
{
name: "forecast simple mode",
args: []string{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", `{"金额":"100"}`},
tool: "forecast_process",
},
{
name: "forecast request mode",
args: []string{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`},
tool: "forecast_process",
},
{
name: "create request mode",
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
tool: "start_process_instance",
},
}
for _, tc := range validCases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, tc.args...); err != nil {
t.Fatalf("execute %v: %v", tc.args, err)
}
if caller.tool != tc.tool || caller.calls != 1 {
t.Fatalf("called tool=%q calls=%d, want %q once", caller.tool, caller.calls, tc.tool)
}
})
}
invalidCases := [][]string{
{"approval", "form-schema"},
{"approval", "forecast-process"},
{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
{"approval", "forecast-process", "--request", "{"},
{"approval", "forecast-process", "--request", "null"},
{"approval", "forecast-process", "--request", "{} {}"},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "bad", "--form-values", `{"金额":"100"}`},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", "["},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`},
{"approval", "create-instance", "--yes"},
{"approval", "create-instance", "--request", "{", "--yes"},
{"approval", "create-instance", "--request", "null", "--yes"},
{"approval", "create-instance", "--request", "{} {}", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", "[", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
}
for _, args := range invalidCases {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, args...); err == nil {
t.Fatalf("invalid args %v returned nil", args)
}
if caller.calls != 0 {
t.Fatalf("invalid args %v made %d MCP calls", args, caller.calls)
}
}
}
@@ -24,16 +24,10 @@ type scriptedToolCaller struct {
format string
dry bool
calls int
server string
tool string
args map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
c.server = serverID
c.tool = toolName
c.args = args
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
-11
View File
@@ -1,11 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
})
}
@@ -271,10 +271,6 @@ func TestCrossPlatformCoverageProtectSheetMutationCommandPanics(t *testing.T) {
func TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety(t *testing.T) {
// Sheet agent hardening: outer --yes-only gate must win over ConfirmSafety
// honoring piped stdin yes (review: delete-sheet / range clear / version revert).
// This fixture exercises the no-caller fallback. Isolate it from commands
// built by earlier tests, which may initialize the package-level deps.
testseam.Protect(t, &deps)
deps = nil
ran := false
cmd := &cobra.Command{
Use: "delete-sheet",
-352
View File
@@ -1,352 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardServerID = "whiteboard"
whiteboardQueryTool = "read_whiteboard_content"
whiteboardUpdateTool = "update_whiteboard"
)
type whiteboardUpdateFile struct {
Overwrite bool `json:"overwrite"`
Source *whiteboardOpenSource `json:"source"`
}
type whiteboardOpenSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
var compactWhiteboardJSON = json.Compact
func newWhiteboardCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "whiteboard",
Selection: contract.ProductSelectionDecl{
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
queryCmd := &cobra.Command{
Use: "query",
Short: "读取白板内容",
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
return err
}
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
})
},
}
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
DeclareLeafMetadata(queryCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "query",
CanonicalPath: "whiteboard.query",
CLIPath: "whiteboard query",
PrimaryCLIPath: "whiteboard query",
},
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
},
},
})
updateCmd := &cobra.Command{
Use: "update",
Short: "追加或整页重建白板内容",
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
return err
}
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
if err != nil {
return err
}
mode := "append"
if input.Overwrite {
mode = "overwrite"
}
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
"mode": mode,
"nodes": nodesJSON,
})
},
}
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
updateExampleIndex := 0
DeclareLeafMetadata(updateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "update",
CanonicalPath: "whiteboard.update",
CLIPath: "whiteboard update",
PrimaryCLIPath: "whiteboard update",
},
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
ExampleDispositions: []contract.ExampleDisposition{{
Index: &updateExampleIndex,
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonLocalState,
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
Reviewed: true,
}},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
{Name: "source", Required: boolPtr(true)},
},
},
})
root.AddCommand(queryCmd, updateCmd)
return root
}
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
for _, name := range []string{"jq", "fields"} {
flag := cmd.Flags().Lookup(name)
if flag == nil {
flag = cmd.InheritedFlags().Lookup(name)
}
if flag != nil && flag.Changed {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
Suggestion: "直接读取命令返回的结构化 JSON",
}
}
}
return nil
}
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
data, err := os.ReadFile(path)
if err != nil {
code := CodeInvalidPath
if os.IsNotExist(err) {
code = CodeFileNotFound
}
return nil, "", &CLIError{
Code: code,
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
Cause: err,
}
}
var input whiteboardUpdateFile
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if input.Source == nil {
return nil, "", invalidWhiteboardSourceParam("source is required")
}
if input.Source.SchemaVersion != "1.0" {
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
}
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
if err != nil {
return nil, "", err
}
if !input.Overwrite && nodeCount == 0 {
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
}
return &input, nodesJSON, nil
}
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errors.Is(err, io.EOF) {
return err
}
return nil
}
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
}
var nodes []json.RawMessage
if err := json.Unmarshal(raw, &nodes); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
for i, node := range nodes {
var object map[string]any
if err := json.Unmarshal(node, &object); err != nil || object == nil {
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
}
}
var compact bytes.Buffer
if err := compactWhiteboardJSON(&compact, raw); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
return compact.String(), len(nodes), nil
}
func invalidWhiteboardSourceJSON(err error) error {
return &CLIError{
Code: CodeInvalidJSON,
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
Cause: err,
}
}
func invalidWhiteboardSourceParam(message string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: message,
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
}
}
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer(whiteboardServerID, toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
if err != nil {
return err
}
if text == "" {
return nil
}
var response map[string]any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&response); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if response == nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
}
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
var result any
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
resultDecoder.UseNumber()
if err := resultDecoder.Decode(&result); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
response["resultJson"] = result
}
return deps.Out.PrintJSON(response)
}
func invalidWhiteboardToolResult(toolName string, err error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: "白板服务返回了无法解析的 JSON",
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
Operation: whiteboardServerID + "/" + toolName,
Cause: err,
}
}
@@ -1,310 +0,0 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
previousMarshal := whiteboardJSONMarshal
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
t.Fatalf("got %q", got)
}
whiteboardJSONMarshal = previousMarshal
previousPrepare := prepareWhiteboardCard
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
caller := &whiteboardTestCaller{}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
t.Fatalf("err=%v", err)
}
previousCompact := compactWhiteboardJSON
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
t.Fatal("expected compact error")
}
}
func TestDocWhiteboardInsertDryRun(t *testing.T) {
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func writeWhiteboardFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "invalid json", content: `{`},
{name: "trailing value", content: `{}` + ` {}`},
{name: "missing source", content: `{}`},
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "unknown field", content: `{"unknown":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing-file error")
}
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
t.Fatal("expected directory read error")
}
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
t.Fatal("expected malformed nodes array error")
}
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
if err != nil || !input.Overwrite || nodes != "[]" {
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
}
}
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
for _, name := range []string{"jq", "fields"} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(name, "", "")
if err := cmd.Flags().Set(name, ".result"); err != nil {
t.Fatal(err)
}
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
t.Fatal("expected rejected output filter")
}
})
}
responses := []string{
`{`,
`{} {}`,
`null`,
`{"resultJson":"{"}`,
`{"resultJson":"{} {}"}`,
}
for _, response := range responses {
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
t.Fatalf("response %q should fail", response)
}
}
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
t.Fatal(err)
}
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
t.Fatal(err)
}
}
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
tests := []struct {
name string
response string
attrs bool
}{
{name: "invalid response", response: `{`},
{name: "missing block", response: `{"blocks":[]}`},
{name: "non object block", response: `{"blocks":[1]}`},
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
installWhiteboardTestCaller(t, caller)
var err error
if test.attrs {
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
} else {
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
}
if err == nil {
t.Fatal("expected query validation error")
}
})
}
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
installWhiteboardTestCaller(t, caller)
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected caller error")
}
}
func TestWhiteboardCommandValidationBranches(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
for _, args := range [][]string{
{"query", "--node", "n"},
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
{"update", "--node", "n", "--part-id", "p"},
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
} {
cmd := newWhiteboardCommand()
cmd.PersistentFlags().String("jq", "", "")
cmd.PersistentFlags().String("fields", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["mode"] != "overwrite" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
if err := cmd.Execute(); err == nil {
t.Fatal("expected source error")
}
}
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
}}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
file := writeWhiteboardFixture(t, "svg")
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
for _, args := range [][]string{
{"media", "upload", "--node", "n"},
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
{"media", "upload", "--node", "n", "--file", t.TempDir()},
} {
cmd = newDocCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestDocMediaUploadRemainingBranches(t *testing.T) {
file := writeWhiteboardFixture(t, "svg")
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *whiteboardTestCaller
response string
}{
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
}}},
} {
t.Run(test.name, func(t *testing.T) {
installWhiteboardTestCaller(t, test.caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected upload error")
}
})
}
}
func TestDocWhiteboardInsertCallerError(t *testing.T) {
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
if index == 0 {
return errors.New("insert")
}
return nil
}}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected insert error")
}
}
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
if got := extractWhiteboardID(nil); got != "" {
t.Fatalf("got %q", got)
}
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
t.Fatalf("got %q", got)
}
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
var value any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
t.Fatal("expected trailing token error")
}
}
-411
View File
@@ -1,411 +0,0 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type whiteboardTestCall struct {
server string
tool string
args map[string]any
}
type whiteboardTestCaller struct {
dry bool
format string
err func(whiteboardTestCall, int) error
response func(whiteboardTestCall, int) string
calls []whiteboardTestCall
}
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
call := whiteboardTestCall{server: server, tool: tool, args: args}
c.calls = append(c.calls, call)
if c.err != nil {
if err := c.err(call, len(c.calls)-1); err != nil {
return nil, err
}
}
text := `{}`
if c.response != nil {
text = c.response(call, len(c.calls)-1)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *whiteboardTestCaller) Format() string { return c.format }
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
func (*whiteboardTestCaller) Fields() string { return "" }
func (*whiteboardTestCaller) JQ() string { return "" }
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
output := &bytes.Buffer{}
deps.Out.w = output
deps.Out.errW = &bytes.Buffer{}
return output
}
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
},
}
output := installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if _, ok := payload["resultJson"].(map[string]any); !ok {
t.Fatalf("resultJson was not decoded: %#v", payload)
}
}
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("err = %v, want cancellation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
t.Fatalf("args = %#v", caller.calls[0].args)
}
}
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
var blockID string
caller := &whiteboardTestCaller{
format: "json",
response: func(call whiteboardTestCall, index int) string {
if index == 0 {
var node []any
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
attrs := node[1].(map[string]any)
blockID = attrs["uuid"].(string)
return `{}`
}
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
encoded, _ := json.Marshal(jsonml)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
}
output := installWhiteboardTestCaller(t, caller)
previousDelays := whiteboardRetryDelays
whiteboardRetryDelays = nil
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
t.Fatalf("unexpected servers: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
if result["whiteboardId"] != "part-real" {
t.Fatalf("output = %#v", payload)
}
}
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
// 让回查桩可以用真实块 ID 组装响应。
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
t.Helper()
raw, _ := call.args["jsonml"].(string)
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
if len(node) < 2 {
t.Fatalf("jsonml node missing attrs: %q", raw)
}
attrs, _ := node[1].(map[string]any)
id, _ := attrs["uuid"].(string)
if id == "" {
t.Fatalf("jsonml node missing uuid: %q", raw)
}
return id
}
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
t.Helper()
previousDelays := whiteboardRetryDelays
previousSleep := whiteboardSleep
stub := make([]time.Duration, delays)
for i := range stub {
stub[i] = time.Millisecond
}
slept := 0
whiteboardRetryDelays = stub
whiteboardSleep = func(time.Duration) { slept++ }
t.Cleanup(func() {
whiteboardRetryDelays = previousDelays
whiteboardSleep = previousSleep
})
return func() int { return slept }
}
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
// 继续带着空 partId 调用 whiteboard query/update。
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
tests := []struct {
name string
queryErr error
queryBody func(blockID string) string
}{
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
{
name: "response missing blocks field",
queryBody: func(string) string { return `{"success":true}` },
},
{
name: "blocks field is not an array",
queryBody: func(string) string { return `{"blocks":{}}` },
},
{
name: "block jsonml unparsable",
queryBody: func(blockID string) string {
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
},
},
{
name: "card node without attrs",
queryBody: func(blockID string) string {
encoded, _ := json.Marshal(`[]`)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blockID := ""
caller := &whiteboardTestCaller{format: "json"}
caller.response = func(call whiteboardTestCall, index int) string {
if index == 0 {
blockID = whiteboardCardBlockID(t, call)
return `{}`
}
if test.queryBody == nil {
return `{}`
}
return test.queryBody(blockID)
}
if test.queryErr != nil {
caller.err = func(_ whiteboardTestCall, index int) error {
if index == 0 {
return nil
}
return test.queryErr
}
}
installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
t.Fatalf("err = %v, want fail-closed verification error", err)
}
if !strings.Contains(err.Error(), blockID) {
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
}
if len(caller.calls) != 2 || slept() != 0 {
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
len(caller.calls), slept())
}
})
}
}
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
// whiteboardId 为 null。
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(_ whiteboardTestCall, index int) string {
if index == 0 {
return `{}`
}
return `{"blocks":[]}`
},
}
output := installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("block-not-visible must stay a soft success: %v", err)
}
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
if len(caller.calls) != 4 || slept() != 2 {
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
whiteboardID, present := result["whiteboardId"]
if payload["success"] != true || !present || whiteboardID != nil {
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
}
if result["blockId"] == "" || result["blockId"] == nil {
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
}
}
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{
name: "ref-block with parent-block",
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
},
{
name: "where with parent-block",
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(test.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
}
if len(caller.calls) != 0 {
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
}
})
}
}
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
},
}
output := installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
t.Fatalf("output = %#v", payload)
}
}
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
uploadURL := "https://upload.example.test/secret-token"
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
},
}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
return fmt.Errorf("PUT %s: connection reset", uploadURL)
}
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
err := cmd.Execute()
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
t.Fatalf("err = %v, want redacted temporary upload URL", err)
}
}
-475
View File
@@ -1,475 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package localio owns safe local artifact publication shared by product
// shortcuts. Remote names and URLs are always treated as untrusted input.
package localio
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
const (
downloadTimeout = 10 * time.Minute
maxDownloadBytes = int64(512 << 20)
)
type downloadTempFile interface {
io.Writer
Sync() error
Close() error
}
var (
createDownloadTemp = createDownloadTempInRoot
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
localGetwd = os.Getwd
localAbs = filepath.Abs
localEvalSymlinks = filepath.EvalSymlinks
openDownloadRoot = os.OpenRoot
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
)
var downloadTempCounter atomic.Uint64
// DownloadOptions controls safe, atomic publication beneath BaseDir.
type DownloadOptions struct {
BaseDir string
Output string
PreferredName string
Headers map[string]string
}
// DownloadResult describes the published local artifact.
type DownloadResult struct {
AbsolutePath string
RelativePath string
SizeBytes int64
}
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
// output path without following symlink escapes, streams into a sibling temp
// file, fsyncs it, and atomically publishes the completed file.
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
}
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
}
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
parsed, err := ValidateDownloadURL(rawURL)
if err != nil {
return DownloadResult{}, err
}
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
if err != nil {
return DownloadResult{}, err
}
defer target.close()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
for key, value := range opts.Headers {
if strings.TrimSpace(key) != "" {
req.Header.Set(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if resp.ContentLength > maxBytes {
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
}
if err := target.verifyParent(); err != nil {
return DownloadResult{}, err
}
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
if err != nil {
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
}
cleanup := func() {
_ = tmp.Close()
_ = target.parentRoot.Remove(tmpName)
}
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
if copyErr == nil && size > maxBytes {
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
}
if copyErr == nil {
copyErr = tmp.Sync()
}
if closeErr := tmp.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
}
if err := target.verifyParent(); err != nil {
cleanup()
return DownloadResult{}, err
}
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
cleanup()
return DownloadResult{}, err
}
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
}
// ValidateOutput rejects absolute paths and portable `..` escapes.
func ValidateOutput(output string) error {
output = strings.TrimSpace(output)
if output == "" {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
}
portable := strings.ReplaceAll(output, "\\", "/")
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
}
clean := pathpkg.Clean(portable)
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
}
return nil
}
// ResolveOutputPath returns a symlink-safe destination below baseDir.
type downloadTarget struct {
baseRoot *os.Root
parentRoot *os.Root
parentInfo os.FileInfo
parentRelative string
destinationName string
absolutePath string
relativePath string
}
func (target *downloadTarget) close() {
_ = target.parentRoot.Close()
_ = target.baseRoot.Close()
}
func (target *downloadTarget) verifyParent() error {
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
if err != nil || !os.SameFile(target.parentInfo, current) {
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
}
return nil
}
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
if err != nil {
return "", "", err
}
defer target.close()
return target.absolutePath, target.relativePath, nil
}
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
if err := ValidateOutput(output); err != nil {
return nil, err
}
if strings.TrimSpace(baseDir) == "" {
var err error
baseDir, err = localGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
}
}
absBase, err := localAbs(baseDir)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
realBase, err := localEvalSymlinks(absBase)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
baseRoot, err := openDownloadRoot(realBase)
if err != nil {
return nil, fmt.Errorf("打开工作目录失败: %w", err)
}
fail := func(err error) (*downloadTarget, error) {
_ = baseRoot.Close()
return nil, err
}
rawOutput := strings.TrimSpace(output)
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
candidate := filepath.Clean(rawOutput)
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
directoryIntent = true
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
}
if directoryIntent {
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
}
parent := filepath.Dir(candidate)
if err := ensureSafeParent(baseRoot, parent); err != nil {
return fail(err)
}
parentRoot, err := openDownloadParent(baseRoot, parent)
if err != nil {
return fail(fmt.Errorf("固定输出目录失败: %w", err))
}
parentInfo, err := downloadRootStat(parentRoot, ".")
if err != nil {
_ = parentRoot.Close()
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
}
currentParent, err := downloadRootStat(baseRoot, parent)
if err != nil || !os.SameFile(parentInfo, currentParent) {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
}
destinationName := filepath.Base(candidate)
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
}
if info.IsDir() {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
}
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
} else if !errors.Is(statErr, os.ErrNotExist) {
_ = parentRoot.Close()
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
}
return &downloadTarget{
baseRoot: baseRoot,
parentRoot: parentRoot,
parentInfo: parentInfo,
parentRelative: parent,
destinationName: destinationName,
absolutePath: filepath.Join(realBase, candidate),
relativePath: candidate,
}, nil
}
// SafeFilename selects a portable basename from a preferred server name or URL.
func SafeFilename(preferredName, rawURL string) string {
if name := sanitizeFilename(preferredName); name != "" {
return name
}
if parsed, err := url.Parse(rawURL); err == nil {
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
if name := sanitizeFilename(decoded); name != "" {
return name
}
}
}
return "download"
}
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
}
return parsed, nil
}
func secureHTTPClient() *http.Client {
transport := &http.Transport{
// Do not use environment proxies here. DialContext must resolve and dial
// the validated download host itself; with a proxy it would receive the
// proxy address and could not enforce the target host's public-IP policy.
Proxy: nil,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := lookupDownloadIPs(ctx, host)
if err != nil {
return nil, err
}
for _, resolved := range ips {
if !publicIP(resolved.IP) {
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
}
}
// Dial the already validated address, not the hostname, to avoid a
// second DNS lookup opening a rebinding window.
var lastErr error
for _, resolved := range ips {
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("下载重定向次数超过上限")
}
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
return err
}
// net/http copies arbitrary request headers from the initial request to
// every redirect. Never forward service-provided download credentials to
// a different origin, even when both hosts are on the download allowlist.
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
req.Header = make(http.Header)
}
return nil
}
return client
}
func sameDownloadOrigin(left, right *url.URL) bool {
return downloadOrigin(left) == downloadOrigin(right)
}
func downloadOrigin(parsed *url.URL) string {
port := parsed.Port()
if port == "" {
port = "443"
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
}
func allowedDownloadHost(host string) bool {
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
}
func publicIP(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(addr) {
return false
}
}
return true
}
var nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
netip.MustParsePrefix("240.0.0.0/4"), // reserved
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
}
func ensureSafeParent(root *os.Root, parent string) error {
if parent == "." {
return nil
}
current := "."
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
current = filepath.Join(current, part)
info, statErr := downloadRootLstat(root, current)
if errors.Is(statErr, os.ErrNotExist) {
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("创建输出目录失败: %w", err)
}
info, statErr = downloadRootLstat(root, current)
}
if statErr != nil {
return fmt.Errorf("检查输出目录失败: %w", statErr)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
}
}
return nil
}
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, "", err
}
return file, name, nil
}
func publishTempFile(root *os.Root, tempName, destinationName string) error {
if err := downloadRootLink(root, tempName, destinationName); err != nil {
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
}
return fmt.Errorf("发布下载文件失败: %w", err)
}
if err := downloadRootRemove(root, tempName); err != nil {
return fmt.Errorf("清理下载临时文件失败: %w", err)
}
return nil
}
func sanitizeFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
return ""
}
return name
}
-726
View File
@@ -1,726 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
type failingBody struct{}
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (failingBody) Close() error { return nil }
type coverageTempFile struct {
file *os.File
writeErr error
syncErr error
closeErr error
onClose func()
}
func (f *coverageTempFile) Write(value []byte) (int, error) {
if f.writeErr != nil {
return 0, f.writeErr
}
return f.file.Write(value)
}
func (f *coverageTempFile) Name() string { return f.file.Name() }
func (f *coverageTempFile) Sync() error {
if f.syncErr != nil {
return f.syncErr
}
return f.file.Sync()
}
func (f *coverageTempFile) Close() error {
err := f.file.Close()
if f.onClose != nil {
f.onClose()
f.onClose = nil
}
if f.closeErr != nil {
return f.closeErr
}
return err
}
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
valid := []string{
"https://alidocs.dingtalk.com/file.docx",
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
}
for _, raw := range valid {
if _, err := ValidateDownloadURL(raw); err != nil {
t.Errorf("ValidateDownloadURL(%q): %v", raw, err)
}
}
invalid := []string{
"http://alidocs.dingtalk.com/file.docx",
"https://127.0.0.1/file.docx",
"https://evil.example/file.docx",
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
"https://user@alidocs.dingtalk.com/file.docx",
"https://alidocs.dingtalk.com:8443/file.docx",
}
for _, raw := range invalid {
if _, err := ValidateDownloadURL(raw); err == nil {
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
}
}
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
if publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = true", raw)
}
}
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
if !publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = false", raw)
}
}
}
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} {
if err := ValidateOutput(output); err == nil {
t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output)
}
}
base := t.TempDir()
destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "")
if err != nil {
t.Fatal(err)
}
realBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") {
t.Fatalf("destination=%q rel=%q", destination, rel)
}
if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("no-clobber error = %v", err)
}
outside := t.TempDir()
link := filepath.Join(base, "outside-link")
if err := os.Symlink(outside, link); err == nil {
if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") {
t.Fatalf("symlink escape error = %v", err)
}
}
if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" {
t.Errorf("SafeFilename traversal basename = %q", got)
}
for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} {
if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" {
t.Errorf("SafeFilename(%q) = %q", name, got)
}
}
}
func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) {
base := t.TempDir()
payload := "first payload"
requests := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" {
return nil, errors.New("unexpected host")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil
})}
result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client)
if err != nil {
t.Fatal(err)
}
if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) {
t.Fatalf("result = %#v", result)
}
got, err := os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("published content = %q, err=%v", got, err)
}
if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("second download error = %v", err)
}
if requests != 1 {
t.Fatalf("existing destination performed %d network requests, want 1 total", requests)
}
got, err = os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("no-clobber content = %q, err=%v", got, err)
}
}
func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) {
base := t.TempDir()
for _, tc := range []struct {
name string
contentLength int64
}{
{name: "declared", contentLength: 6},
{name: "streamed", contentLength: -1},
} {
t.Run(tc.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("123456")),
Header: make(http.Header),
ContentLength: tc.contentLength,
}, nil
})}
output := tc.name + ".bin"
if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: output,
}, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") {
t.Fatalf("oversized download error = %v", err)
}
if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("oversized destination exists: %v", err)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-download-") {
t.Fatalf("oversized download left temp file %q", entry.Name())
}
}
})
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
if err := os.Rename(parent, original); err != nil {
t.Skipf("platform cannot replace an open directory: %v", err)
}
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
if renameErr := os.Rename(parent, original); renameErr != nil {
t.Skipf("platform cannot replace an open directory: %v", renameErr)
}
if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil {
t.Fatal(mkdirErr)
}
}}, name, nil
})
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/file.bin"
if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil {
t.Fatal("invalid URL download succeeded")
}
if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil {
t.Fatal("unsafe output download succeeded")
}
clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })}
statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" {
t.Errorf("headers = %#v", req.Header)
}
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil
})}
bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil {
t.Fatal("transport error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil {
t.Fatal("HTTP status error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil {
t.Fatal("body read error was ignored")
}
okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
for _, tc := range []struct {
name string
makeTemp func(*os.Root) (downloadTempFile, string, error)
}{
{"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
{"sync", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil
}},
{"close", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil
}},
{"publish-race", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if createErr == nil {
_ = file.Close()
}
}}, name, nil
}},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &createDownloadTemp, tc.makeTemp)
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil {
t.Fatalf("%s failure was ignored", tc.name)
}
})
}
}
func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
client := secureHTTPClient()
transport := client.Transport.(*http.Transport)
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
t.Fatal("redirect limit accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
t.Fatal("unsafe redirect accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
t.Fatal(err)
}
if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil {
t.Fatal("bad address dial succeeded")
}
t.Run("lookup error", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("lookup error ignored")
}
})
t.Run("private answer", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
})
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("private DNS answer accepted")
}
})
t.Run("public dial fallback and success", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
})
left, right := net.Pipe()
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
calls := 0
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, errors.New("first")
}
return left, nil
})
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
t.Fatal(err)
} else {
_ = conn.Close()
}
})
t.Run("all public dials fail", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
})
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("dial failure ignored")
}
})
base := t.TempDir()
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("missing base succeeded")
}
dir := filepath.Join(base, "directory")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
for _, output := range []string{".", "directory/", "directory"} {
if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil {
t.Errorf("directory output %q: %v", output, err)
}
}
if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
targetDir := filepath.Join(base, "target-dir")
_ = os.Mkdir(targetDir, 0o700)
if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(base, "existing.txt")
_ = os.WriteFile(targetFile, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("existing destination error = %v", err)
}
link := filepath.Join(base, "target-link")
if err := os.Symlink(targetFile, link); err == nil {
if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("symlink destination accepted")
}
}
fileParent := filepath.Join(base, "file-parent")
_ = os.WriteFile(fileParent, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("file parent accepted")
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = root.Close() })
if err := ensureSafeParent(root, "../escape"); err == nil {
t.Fatal("escaping parent accepted")
}
if err := ensureSafeParent(root, "."); err != nil {
t.Fatal(err)
}
source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_, _ = source.WriteString("x")
_ = source.Close()
destination := filepath.Join(base, "publish.txt")
_ = os.WriteFile(destination, []byte("old"), 0o600)
if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil {
t.Fatal("publish existing destination succeeded")
}
if err := publishTempFile(root, "missing-source", "new.txt"); err == nil {
t.Fatal("publish missing source succeeded")
}
symlinkDestination := filepath.Join(base, "publish-link")
if err := os.Symlink(destination, symlinkDestination); err == nil {
if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil {
t.Fatal("publish to symlink succeeded")
}
}
closedRoot, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
_ = closedRoot.Close()
if _, _, err := createDownloadTempInRoot(closedRoot); err == nil {
t.Fatal("temp creation in closed root succeeded")
}
for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} {
_ = sanitizeFilename(name)
}
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
_ = SafeFilename("", "://bad")
_ = publicIP(net.IP{1, 2, 3})
}
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
transport := secureHTTPClient().Transport.(*http.Transport)
if transport.Proxy != nil {
t.Fatal("secure download client accepted an environment proxy")
}
}
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
client := secureHTTPClient()
original := &http.Request{
URL: mustURL(t, "https://download.dingtalk.com/source"),
Header: http.Header{
"X-Oss-Security-Token": []string{"credential-a"},
"X-Download-Auth": []string{"credential-b"},
},
}
sameOrigin := &http.Request{
URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if sameOrigin.Header.Get("X-Oss-Security-Token") == "" {
t.Fatal("same-origin redirect unexpectedly stripped request headers")
}
crossOrigin := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if len(crossOrigin.Header) != 0 {
t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header))
}
multiHop := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil {
t.Fatal(err)
}
if len(multiHop.Header) != 0 {
t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header))
}
}
func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/x"
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil {
t.Fatal("cancelled default client download succeeded")
}
t.Run("getwd", func(t *testing.T) {
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
_, _, _ = ResolveOutputPath("", "x", validURL, "")
})
t.Run("abs", func(t *testing.T) {
testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("eval base", func(t *testing.T) {
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("open base", func(t *testing.T) {
testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") })
_, _, _ = ResolveOutputPath(base, "new/target", validURL, "")
})
t.Run("lstat after mkdir", func(t *testing.T) {
calls := 0
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "new-after" {
calls++
if calls > 1 {
return nil, errors.New("after mkdir")
}
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "")
})
t.Run("open parent", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") })
_, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "")
})
t.Run("parent stat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "." {
return nil, errors.New("parent stat")
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "")
})
t.Run("parent identity", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil {
t.Fatal(err)
}
otherInfo, err := os.Stat(t.TempDir())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "parent-identity" {
return otherInfo, nil
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "")
})
t.Run("destination directory", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil {
t.Fatal(err)
}
dirInfo, err := os.Stat(base)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return dirInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "")
})
t.Run("destination symlink", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "coverage-link")
if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
linkInfo, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return linkInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "")
})
t.Run("destination lstat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return nil, errors.New("destination lstat")
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "")
})
t.Run("unsafe parent type", func(t *testing.T) {
filePath := filepath.Join(base, "unsafe-parent")
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := ensureSafeParent(root, "unsafe-parent"); err == nil {
t.Fatal("regular file accepted as output parent")
}
})
t.Run("publish remove", func(t *testing.T) {
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_ = file.Close()
testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") })
if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil {
t.Fatal("publish remove error ignored")
}
})
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return parsed
}
-30
View File
@@ -1,30 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// Package profilectx owns the process-local profile selector without importing
// authentication or transport packages.
package profilectx
import (
"strings"
"sync"
)
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// Set records the explicit profile selector for the current process.
func Set(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// Get returns the explicit process-local profile selector.
func Get() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
-20
View File
@@ -1,20 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package profilectx
import "testing"
func TestCrossPlatformCoverageSetAndGet(t *testing.T) {
t.Cleanup(func() { Set("") })
Set(" fixture-profile ")
if got := Get(); got != "fixture-profile" {
t.Fatalf("Get() = %q, want fixture-profile", got)
}
Set("")
if got := Get(); got != "" {
t.Fatalf("Get() after reset = %q, want empty", got)
}
}
+1 -75
View File
@@ -36,16 +36,6 @@ func FromShortcut(s Shortcut) corecmd.Spec {
if !safetySpecDeclared(safety) {
safety = shortcutSafetySpec(s.risk())
}
declaredContract := s.Contract
if !declaredContract.Empty() && len(s.Aliases) > 0 && len(declaredContract.Identity.Aliases) == 0 {
declaredContract.Identity.Aliases = make([]string, 0, len(s.Aliases))
for _, alias := range s.Aliases {
declaredContract.Identity.Aliases = append(
declaredContract.Identity.Aliases,
s.Service+" "+strings.TrimSpace(alias),
)
}
}
return corecmd.Spec{
Use: s.Command,
Short: s.Description,
@@ -57,12 +47,11 @@ func FromShortcut(s Shortcut) corecmd.Spec {
Flags: fromShortcutFlags(s.Flags),
Constraints: fromShortcutConstraints(s.Constraints),
Safety: safety,
Contract: declaredContract,
Contract: s.Contract,
// Preserve the shipped Shortcut Catalog provenance: Cobra remains the
// source for type/default/usage, while command adds Required/Enum/rules.
ParameterProjection: corecmd.ProjectCobraParameters,
Validate: fromShortcutValidate(s),
PostMount: fromShortcutPostMount(s),
// Multi-step body: command stays backend-agnostic, so the shortcut's own
// RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is
// built here from the Ctx's command.
@@ -76,57 +65,6 @@ func FromShortcut(s Shortcut) corecmd.Spec {
}
}
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
hasVisibleFlagAliases := false
for _, flag := range s.Flags {
if flag.AliasesVisible && len(flag.Aliases) > 0 {
hasVisibleFlagAliases = true
break
}
}
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" && !hasVisibleFlagAliases {
return nil
}
return func(cmd *cobra.Command) {
cmd.Aliases = append([]string(nil), s.Aliases...)
for _, flag := range s.Flags {
if !flag.AliasesVisible {
continue
}
for _, alias := range flag.Aliases {
if mounted := cmd.Flags().Lookup(alias); mounted != nil {
mounted.Hidden = false
}
}
}
name := strings.TrimSpace(s.SinglePositionalAliasFor)
if name == "" {
return
}
cmd.Args = func(cmd *cobra.Command, args []string) error {
if err := cobra.MaximumNArgs(1)(cmd, args); err != nil {
return err
}
if len(args) == 0 {
return nil
}
flag := cmd.Flags().Lookup(name)
if flag == nil {
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s positional alias flag --%s is not registered",
s.Service, s.Command, name))
}
if flag.Changed {
return apperrors.NewValidation(fmt.Sprintf("位置参数与 --%s 不能同时提供", name))
}
if err := cmd.Flags().Set(name, args[0]); err != nil {
return apperrors.NewValidation(fmt.Sprintf("位置参数无法写入 --%s: %v", name, err))
}
return nil
}
}
}
func safetySpecDeclared(safety contract.SafetySpec) bool {
return strings.TrimSpace(safety.Effect) != "" ||
strings.TrimSpace(safety.Risk) != "" ||
@@ -134,16 +72,6 @@ func safetySpecDeclared(safety contract.SafetySpec) bool {
strings.TrimSpace(safety.Idempotency) != ""
}
// EffectiveSafety returns the exact safety declaration used by the runtime and
// ContractFinal. Management/listing projections must use this instead of
// re-inferring confirmation from the legacy Risk enum.
func EffectiveSafety(s Shortcut) contract.SafetySpec {
if safetySpecDeclared(s.Safety) {
return s.Safety
}
return shortcutSafetySpec(s.risk())
}
func shortcutExamples(tips []string) string {
if len(tips) == 0 {
return ""
@@ -205,7 +133,6 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
for _, f := range flags {
out = append(out, corecmd.FlagSpec{
Name: f.Name,
Shorthand: f.Shorthand,
Usage: flagHelp(f),
Kind: fromShortcutFlagKind(f.Type),
Default: f.Default,
@@ -215,7 +142,6 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
ValidationMode: corecmd.ValidationShortcut,
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
Enum: append([]string(nil), f.Enum...),
Aliases: append([]string(nil), f.Aliases...),
})
}
return out
+2 -79
View File
@@ -20,7 +20,6 @@ import (
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// TestCrossPlatformCoverageFromShortcutMapsSharedBase verifies FromShortcut
@@ -34,7 +33,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
Hidden: true,
Tips: []string{"dws chat +demo --name a"},
Flags: []Flag{
{Name: "name", Shorthand: "n", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
{Name: "count", Type: FlagInt, Desc: "数量"},
{Name: "flag", Type: FlagBool, Desc: "开关"},
{Name: "ids", Type: FlagStringSlice, Desc: "列表"},
@@ -66,13 +65,6 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
}
if got := EffectiveSafety(Shortcut{Risk: RiskWrite}); got.Effect != "write" || got.Confirmation != "user_required" {
t.Fatalf("legacy effective safety = %#v", got)
}
explicit := contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
if got := EffectiveSafety(Shortcut{Risk: RiskHighWrite, Safety: explicit}); got != explicit {
t.Fatalf("explicit effective safety = %#v, want %#v", got, explicit)
}
if cs.Orchestrate == nil {
t.Fatal("multi-step Execute must project into Orchestrate")
}
@@ -93,7 +85,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
name := cs.Flags[0]
if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" ||
if name.Name != "name" || !name.Required || name.Default != "d" ||
!name.Hidden || name.ValidationMode != corecmd.ValidationShortcut ||
name.RequiredError != "缺少必填参数 --name:名称" ||
strings.Join(name.Enum, ",") != "a,b" {
@@ -128,75 +120,6 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
}
}
func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T) {
executed := ""
s := Shortcut{
Service: "chat",
Command: "+search",
Aliases: []string{"+search-group"},
SinglePositionalAliasFor: "query",
Description: "搜索群",
Intent: "按名称搜索群",
Contract: corecmd.ContractDecl{
Description: "按名称搜索群",
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "test composite",
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索群", UseWhen: []string{"按名称搜索群"}, AvoidWhen: []string{"不要用于成员搜索"}, Examples: []string{"dws chat +search --query demo"},
},
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
},
},
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true, Aliases: []string{"keyword"}, AliasesVisible: true}},
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
}
spec := FromShortcut(s)
if got := spec.Contract.Identity.Aliases; len(got) != 1 || got[0] != "chat +search-group" {
t.Fatalf("contract aliases = %#v", got)
}
cmd := mount(s)
if !cmd.HasAlias("+search-group") {
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
}
if alias := cmd.Flags().Lookup("keyword"); alias == nil || alias.Hidden {
t.Fatalf("historically public flag alias = %#v, want visible", alias)
}
cmd.SetArgs([]string{"项目群"})
if err := cmd.Execute(); err != nil || executed != "项目群" {
t.Fatalf("positional execute err=%v value=%q", err, executed)
}
conflict := mount(s)
conflict.SetArgs([]string{"项目群", "--query", "另一个群"})
if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "不能同时提供") {
t.Fatalf("positional/flag conflict err = %v", err)
}
tooMany := mount(s)
tooMany.SetArgs([]string{"one", "two"})
if err := tooMany.Execute(); err == nil {
t.Fatal("multiple positional aliases unexpectedly accepted")
}
missing := mount(Shortcut{
Service: "chat", Command: "+missing", Description: "missing",
SinglePositionalAliasFor: "query", Execute: func(*RuntimeContext) error { return nil },
})
missing.SetArgs([]string{"value"})
if err := missing.Execute(); err == nil || !strings.Contains(err.Error(), "is not registered") {
t.Fatalf("missing positional flag err = %v", err)
}
invalid := mount(Shortcut{
Service: "chat", Command: "+invalid", Description: "invalid",
SinglePositionalAliasFor: "query", Flags: []Flag{{Name: "query", Type: FlagInt}}, Execute: func(*RuntimeContext) error { return nil },
})
invalid.SetArgs([]string{"not-an-int"})
if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "无法写入") {
t.Fatalf("invalid positional value err = %v", err)
}
}
// TestCrossPlatformCoverageFromShortcutMatchesMountSurface pins the live
// adapter surface: flag set (names/types/usage) and rendered Long must agree.
// This catches a double-rendered 参数约束 or lost flagHelp decoration.
+3 -4
View File
@@ -12,10 +12,9 @@
// limitations under the License.
// Package builtin aggregates all built-in shortcut service packages via blank
// imports so their init() registrations run, applies the reviewed semantic and
// public-catalog decorations in the core registry, then re-exports the compiled
// cobra commands. The host application depends only on this package, keeping
// the service packages free to import the core shortcut package without a cycle.
// imports so their init() registrations run, then re-exports the compiled cobra
// commands. The host application depends only on this package, keeping the
// service packages free to import the core shortcut package without a cycle.
//
// Add a blank import here when a new service package is generated under
// internal/shortcut/<service>/.
@@ -27,7 +27,7 @@ import (
// TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential
// guard for the live mount migration. It derives the historical Cobra surface
// directly from each Shortcut declaration and checks the command-built tree.
func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
mounted := map[string]*cobra.Command{}
for _, service := range builtin.BaseCommands() {
for _, command := range service.Commands() {
@@ -83,18 +83,6 @@ func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t
spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden)
}
assertShortcutDefault(t, command, spec, flag)
for _, alias := range flag.Aliases {
declaredFlags[alias] = flag
gotAlias := command.Flags().Lookup(alias)
if gotAlias == nil {
t.Errorf("%s %s: flag alias --%s is not mounted", spec.Service, spec.Command, alias)
continue
}
wantHidden := !flag.AliasesVisible
if gotAlias.Hidden != wantHidden {
t.Errorf("%s %s: flag alias --%s hidden = %v, want %v", spec.Service, spec.Command, alias, gotAlias.Hidden, wantHidden)
}
}
}
command.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Name == "help" {
@@ -1,83 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_doc.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "doc" {
t.Fatalf("semantic catalog service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
if len(registered) != 47 || len(source.Shortcuts) != 47 {
t.Fatalf("registered/catalog = %d/%d, want 47/47", len(registered), len(source.Shortcuts))
}
var missing, stale []string
public, hidden := 0, 0
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || record.SemanticDelta != item.SemanticDelta || record.Disposition != item.Disposition {
t.Errorf("%s: reviewed semantic delivery mismatch", command)
}
if got := shortcut.InPublicCatalog("doc", command); got != record.Public || item.Hidden == record.Public {
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
}
if record.Public {
public++
if item.Contract.Empty() {
t.Errorf("%s: public Doc shortcut has empty Contract", command)
}
} else {
hidden++
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 45 || hidden != 2 {
t.Fatalf("public/hidden = %d/%d, want 45/2", public, hidden)
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
if item.Disposition != shortcut.DispositionAliasInternal || item.PrimaryCommand != primary {
t.Errorf("%s compatibility routing = %s/%s, want alias_internal/%s", command, item.Disposition, item.PrimaryCommand, primary)
}
}
}
+17 -41
View File
@@ -5,9 +5,6 @@
package chat
import (
"fmt"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -31,15 +28,14 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
})
}
return rt.Output(map[string]any{
"contractVersion": "im.batch-write.v1",
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"tool": tool,
"actionCount": len(actions),
"failedCount": 0,
"actions": actions,
"requestedCount": len(items),
})
}
@@ -60,33 +56,13 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
}
succeeded = append(succeeded, entry)
}
result := map[string]any{
"contractVersion": "im.batch-write.v1",
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
}
if err := rt.Output(result); err != nil {
return err
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("批量执行 %s 失败:%d/%d 个目标未完成", tool, len(failures), len(items)),
apperrors.WithOperation(product+"/"+tool),
apperrors.WithReason("batch_write_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"partial": len(succeeded) > 0,
}),
)
}
return nil
return rt.Output(map[string]any{
"ok": len(failures) == 0,
"partial": len(succeeded) > 0 && len(failures) > 0,
"requestedCount": len(items),
"succeededCount": len(succeeded),
"failedCount": len(failures),
"succeeded": succeeded,
"failures": failures,
})
}
+2 -2
View File
@@ -228,8 +228,8 @@ func botFindProject(data map[string]any) []map[string]any {
// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server).
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
BotSearch,
BotFind,
)...)
)
}
+30 -140
View File
@@ -15,7 +15,6 @@ package chat
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
@@ -89,8 +88,8 @@ var ConversationSetTop = shortcut.Shortcut{
Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
},
Constraints: []shortcut.Constraint{
@@ -165,7 +164,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{
Command: "+conversation-mute-at-all",
Product: "im",
Description: "关闭/开启 @所有人消息提醒",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -186,7 +185,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{
Command: "+conversation-mute-red-envelope",
Product: "im",
Description: "关闭/开启红包消息提醒",
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
@@ -285,8 +284,8 @@ var ConversationList = shortcut.Shortcut{
Service: "chat",
Command: "+conversation-list",
Product: "im",
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。",
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -300,143 +299,47 @@ var ConversationList = shortcut.Shortcut{
CLIPath: "chat +conversation-list",
PrimaryCLIPath: "chat +conversation-list",
},
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"},
AgentSummary: "分页获取当前用户的全部会话列表(单聊+群聊)",
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +conversation-list --limit 50"},
},
},
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +conversation-list --limit 50`,
`dws chat +conversation-list --page-all --limit 100`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1-100 之间;读取全部会话请使用 --page-all")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
return nil
},
Tips: []string{`dws chat +conversation-list --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
cursor := int64(rt.Int("cursor"))
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
}
convs := make([]map[string]any, 0)
seenConversations := map[string]bool{}
seenCursors := map[int64]bool{cursor: true}
pagesFetched := 0
complete := false
hasMore := false
nextCursor := int64(0)
failures := make([]map[string]any, 0)
for pagesFetched < pageLimit {
params := map[string]any{"limit": rt.Int("limit")}
if cursor > 0 {
params["cursor"] = cursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
if pagesFetched == 0 {
return err
}
failures = append(failures, map[string]any{"stage": "conversation-page", "cursor": cursor, "error": err.Error()})
break
}
pagesFetched++
for _, conversation := range conversationListProject(data) {
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
if id != "" && id != "<nil>" {
if seenConversations[id] {
continue
}
seenConversations[id] = true
}
convs = append(convs, conversation)
}
page := chatmsg.Pagination(data)
hasMoreValue, known := page["hasMore"].(bool)
hasMore = hasMoreValue
if !known {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
break
}
if !hasMore {
complete = true
break
}
nextCursor, err = conversationPaginationCursor(page["nextCursor"])
if err != nil || nextCursor == 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "hasMore=true 但 nextCursor 缺失、无效或未前进"})
break
}
if !rt.Bool("page-all") {
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
if rt.Int("cursor") > 0 {
params["cursor"] = rt.Int("cursor")
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
payload := map[string]any{
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
return err
}
convs := conversationListProject(data)
payload := map[string]any{"count": len(convs), "conversations": convs}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
}
func conversationPaginationCursor(value any) (int64, error) {
switch typed := value.(type) {
case int:
return int64(typed), nil
case int64:
return typed, nil
case float64:
return int64(typed), nil
case string:
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
// conversationListProject reshapes the raw list_all_conversations response into a
// clean conversation list — clean output projection. Both the list
// container and the per-item field names are probed defensively across candidate
@@ -476,12 +379,12 @@ func conversationListResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
}
}
@@ -489,19 +392,6 @@ func conversationListResolveList(data map[string]any) []any {
return []any{}
}
// unwrapConversationTuple handles gateway responses shaped as
// result:[conversationList,nextCursor,hasMore] while leaving ordinary arrays
// untouched. This prevents the first list from being mistaken for one row.
func unwrapConversationTuple(values []any) []any {
if len(values) == 0 {
return values
}
if nested, ok := values[0].([]any); ok {
return nested
}
return values
}
// conversationListFirst returns the first present candidate key's value.
func conversationListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
@@ -686,12 +576,12 @@ func conversationListTopResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return unwrapConversationTuple(arr)
return arr
}
}
}
@@ -1180,7 +1070,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(
shortcut.Register(
ConversationInfo,
ConversationSetTop,
ConversationMute,
@@ -1201,5 +1091,5 @@ func init() {
CategoryRename,
CategoryAddConversation,
CategoryRemoveConversation,
)...)
)
}

Some files were not shown because too many files have changed in this diff Show More