Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -70,17 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — re-run any time.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -393,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -719,7 +719,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+12
-12
@@ -70,17 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),随时重跑都行。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -387,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -708,7 +708,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+134
-8
@@ -127,6 +127,15 @@ function installSkillsToHomes(skillRoot) {
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
// Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
// Directories only — a stray file named dingtalk-x.md must survive.
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && (entry.name.startsWith("dingtalk-") || entry.name === "dws-shared")) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
@@ -138,23 +147,122 @@ function installSkillsToHomes(skillRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale dingtalk-* skills not present
|
||||
// in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
let installed = 0;
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.rmSync(path.join(baseDir, "dws"), { recursive: true, force: true });
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(entry.name.startsWith("dingtalk-") || entry.name === "dws-shared") &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
const destDir = path.join(baseDir, name);
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(path.join(multiRoot, name), destDir);
|
||||
}
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
installToBase(baseDir);
|
||||
installed += 1;
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
installToBase(path.join(homeDir, ".agents", "skills"));
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
@@ -191,7 +299,25 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Skill 分发/安装架构优化方案
|
||||
|
||||
> 目标:把 DWS Agent skill 的"装在哪、怎么装、装完记什么"从 7+ 个安装面的
|
||||
> N 份漂移拷贝,收敛为 **Go 侧单一事实源 + 单一安装引擎 + 脚本侧 bootstrap**。
|
||||
> 本文基于 feat/skill-mode-migration 工作区(multi 化落地后)的代码级盘点
|
||||
> (2026-08-05),所有锚点均可跳转验证。
|
||||
> 前置文档:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)(下称《迁移计划》)、
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 1. 问题量化
|
||||
|
||||
### 1.1 复制矩阵(能力 × 面)
|
||||
|
||||
multi 化之后,同一块逻辑在各安装面的拷贝数(✓ = 独立拷贝一份,数字 = 份数):
|
||||
|
||||
| 能力 \ 面 | install.sh | install.ps1 | npm install.js | install-skills.sh | install-event.sh | install-devapp.sh(+ps1) | Homebrew caveats | `dws skill setup` (Go) | `dws upgrade` (Go) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| agent home 清单 | ✓×3 | ✓ | ✓ | ✓×2 | ✓ | ✓×2 | — | ✓ | ✓ |
|
||||
| 互斥清理(mono↔multi) | ✓×2(其中 1 份死代码) | ✓ | ✓ | ✓ | — | — | — | ✓ | ✓ |
|
||||
| mode 解析(env/flag/交互) | ✓ | ✓ | ✓ | — | — | — | — | ✓ | 布局嗅探 |
|
||||
| stale `dingtalk-*` 清理 | ✓ | ✓ | ✓ | ✓ | — | — | — | ✗(additive,不清) | ✓ |
|
||||
| `~/.dws/skills` 缓存写 | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | —(只读回退) | ✓(仅 multi) |
|
||||
| 安装状态(state.json) | 无 | 无 | 无 | 无 | 无 | 无 | — | 无 | 无 |
|
||||
|
||||
清单锚点(agent home 清单,全仓共 **15 份**):
|
||||
|
||||
| # | 位置 | 备注 |
|
||||
|---|---|---|
|
||||
| 1 | `internal/app/skill_setup.go:20-37` | `skillSetupAgentHomes`,16 项,**无 opencode** |
|
||||
| 2 | `internal/app/skill_command.go:119-141` | `agentSkillPaths`,17 项,含 opencode(`:129`) |
|
||||
| 3 | `internal/upgrade/paths.go:35-52` | `knownSkillDirs`,16 项,**无 opencode** |
|
||||
| 4 | `scripts/install.sh:370-386` | mono 安装循环内联清单 |
|
||||
| 5 | `scripts/install.sh:439-455` | multi 清单(**死代码**,见 1.2) |
|
||||
| 6 | `scripts/install.sh:516-532` | multi 清单(生效) |
|
||||
| 7 | `scripts/install.ps1:44-61` | `$AgentDirs` |
|
||||
| 8 | `build/npm/install.js:11-28` | `AGENT_DIRS` |
|
||||
| 9 | `scripts/install-skills.sh:166-182` | multi 清单 |
|
||||
| 10 | `scripts/install-skills.sh:247-261` | mono 清单 |
|
||||
| 11 | `scripts/install-event.sh:103-107` | 17 项,**含 `.config/opencode/skills`**(`:107`) |
|
||||
| 12 | `scripts/install-devapp.sh:87-91` | 含 opencode(`:91`) |
|
||||
| 13 | `scripts/install-devapp.ps1:106` | 含 opencode |
|
||||
| 14 | `test/scripts/package_script_test.go` | `expectedPackagedSkillTargets` |
|
||||
| 15 | `scripts/release/verify-package-managers.sh:75-76` | `HOME_AGENT_PARENTS` / `HOME_SKILL_TARGETS` |
|
||||
|
||||
互斥清理逻辑共 **≥8 份**:`skill_setup.go:570-608`、`paths.go:306-327` +
|
||||
`paths.go:163-179`(mono 分支内联)、`install.sh:394-399` + `install.sh:557-570`
|
||||
+ 死代码 `install.sh:477-486`、`install.ps1:488-498` + `install.ps1:562-570`、
|
||||
`install.js:130-137` + `install.js:165-178`、`install-skills.sh:207-220`。
|
||||
|
||||
mode 解析共 **4 份**:`skill_setup.go:343-376`、`install.sh:220-257`、
|
||||
`install.ps1:293-332`、`install.js:197-214`(各自实现 env 优先级、非法值报错、
|
||||
TTY 交互、非 TTY 默认值,措辞与行为细节已不完全一致)。
|
||||
|
||||
stale `dingtalk-*` 清理共 **5 份**:`paths.go:317-325`、`install.sh:561-567`、
|
||||
`install.ps1:562-570`、`install.js:168-172`、`install-skills.sh:211-220`。
|
||||
|
||||
缓存写共 **6 份**:`install.sh:326-344`(multi)+ `install.sh:349-360`(mono)、
|
||||
`install.ps1:446-471`、`install.js:221-237`、`install-event.sh:165-169`、
|
||||
`install-devapp.sh:82-84`、`paths.go:290-296`(upgrade 只刷 multi)。
|
||||
|
||||
### 1.2 已发生的漂移实例(不是假设,是现状)
|
||||
|
||||
1. **opencode 清单漂移**:`dws skill install` 支持 opencode 目标
|
||||
(`skill_command.go:129`),install-event / install-devapp 也往
|
||||
`~/.config/opencode/skills` 装(`install-event.sh:107`、
|
||||
`install-devapp.sh:91`),但 `dws skill setup --target all` 与
|
||||
`dws upgrade` 的清单都不含它(`skill_setup.go:20-37`、
|
||||
`paths.go:35-52`)。结果:opencode 用户能收到 event/dev skill,
|
||||
却永远收不到内置 skill 的安装与升级。
|
||||
2. **install.sh 死代码**:`install_multi_skills_to_homes` 定义了两次
|
||||
(`install.sh:434` 与 `install.sh:511`),`_install_multi_to_base`
|
||||
同样两次(`install.sh:473` 与 `install.sh:549`)。bash 后定义覆盖先定义,
|
||||
第一对(434-505)整体是死代码——本次 multi 化自己引入的重复。
|
||||
3. **"镜像"注释与实现已不符**:`install.sh:507-510` 注释声称 multi 安装
|
||||
"mirroring `dws skill setup --mode multi`",但脚本会删除 bundle 里不存在
|
||||
的 stale `dingtalk-*`(`install.sh:561-567`),而 setup 是 additive 语义、
|
||||
只清 `dws/`(`skill_setup.go:587-593`)。注释说镜像,行为已分叉。
|
||||
4. **互斥语义自相矛盾**:install-event.sh 把 multi 的 `dingtalk-event` 和
|
||||
mono 的 `dws` **同时**装进同一批 agent home(`install-event.sh:171-172`),
|
||||
与其他所有面"mono/multi 互斥"的语义直接冲突。
|
||||
5. **缓存只写不读、版本不可见**:`dws skill setup` 默认走二进制 embed
|
||||
(`skill_setup_embed.go:50-59`),`~/.dws/skills` 只是 legacy 回退候选
|
||||
(`skill_setup.go:442-447`);6 个写入方没有任何版本戳,upgrade 只刷
|
||||
multi 不刷 mono(`paths.go:292-296`),缓存与 embed 漂移不可见。
|
||||
6. **保留前缀规则已双份**:`dingtalk-` / `dws-shared` 的保留约定在
|
||||
`skill_setup.go:199`(`multiSkillPrefix`)、`skill_setup.go:205`
|
||||
(`multiSharedSkill`)与 `paths.go:332-334`(`isMultiSkillDirName`)
|
||||
各写一份,互斥清理依赖"市场 skill 不用该前缀"这一隐性约定。
|
||||
|
||||
### 1.3 维护成本论证(本次 multi 化实证)
|
||||
|
||||
本次"把 7 个面全部 multi 化"这一个语义变更,工作区改动为 **13 个文件、
|
||||
+759/−166 行**(`git diff --stat`),横跨 Go / POSIX sh / PowerShell /
|
||||
JavaScript 四种语言:
|
||||
|
||||
| 文件 | 改动量 | 改了什么 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | +256/−… | mode 解析、multi 安装×2(含死代码)、互斥清理、缓存写 |
|
||||
| `scripts/install.ps1` | +169 | 同上,PowerShell 再写一遍 |
|
||||
| `build/npm/install.js` | +87 | 同上,JavaScript 再写一遍 |
|
||||
| `scripts/install-skills.sh` | +101 | multi 安装 + stale 清理 |
|
||||
| `internal/upgrade/paths.go` | +206 | upgrade multi 刷新 + 互斥清理 + 缓存 |
|
||||
| `internal/app/skill_setup.go` 等 Go 侧 | ~+50 | setup multi 语义 |
|
||||
| 测试与文档 | 其余 | 4 个测试文件 + README×2 + SKILL.md |
|
||||
|
||||
即:**一个语义变更 = 4 种语言 × 7+ 处同步编辑**,且仍然漏了 opencode、
|
||||
制造了死代码、留下了语义分叉(1.2)。新增一个 agent home 今天要改 15 份
|
||||
清单中的至少 11 份(另 4 份是测试/校验)。这不是"以后会漂移",而是
|
||||
**每一次改动都在当场制造漂移**。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 分层总览
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ L3 分发面(7 个,全部退化为 bootstrap) │
|
||||
│ install.sh / install.ps1 / npm install.js / install-skills.sh /│
|
||||
│ install-event.sh / install-devapp.sh / Homebrew caveats │
|
||||
│ 职责:装二进制 → exec `dws skill setup --mode X --yes` │
|
||||
│ └─ 失败 → 冻结的内嵌 fallback 拷贝(一档,不再演进) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L2 单一安装引擎(Go,唯二入口) │
|
||||
│ dws skill setup ─┐ │
|
||||
│ dws upgrade ─┴─► 共用同一 install 实现(含互斥清理/记账) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L1 单一事实源:internal/skillhome(新共享包) │
|
||||
│ AgentHomes() 清单 + 布局规则(mono→<home>/dws,multi→平铺) │
|
||||
│ + 互斥/ stale 清理规则 + 保留前缀常量 + state.json 读写 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L0 事实数据 │
|
||||
│ //go:embed skills/{mono,multi}(skills_embed.go:28,默认源) │
|
||||
│ ~/.dws/skills/state.json(权威安装状态) │
|
||||
│ ~/.dws/skills/{mono,multi}(显式回退源,带版本戳,唯一写方=Go) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ 门禁:scripts/policy/check-agent-homes-sync.sh(进 make policy)
|
||||
比对各脚本可解析清单块 ↔ skillhome 导出清单
|
||||
```
|
||||
|
||||
### 2.2 单一事实源:`internal/skillhome` 共享包
|
||||
|
||||
新建 `internal/skillhome`,把今天散在 3 处 Go 代码里的规则合并导出,
|
||||
setup / upgrade / 测试共用(对应《迁移计划》P0c-1):
|
||||
|
||||
| 导出物 | 收敛的现有拷贝 |
|
||||
|---|---|
|
||||
| `AgentHomes()`(含 opencode,带"首项必装/其余父目录门控"元数据) | `skill_setup.go:20-37`、`paths.go:35-52`,并与 `skill_command.go:119-141` 互相断言 |
|
||||
| `HomeForMode(base, mode)` 布局规则 | `skill_setup.go:502-507`(`agentHomeForMode`) |
|
||||
| `MutualExclusionVictims(home, mode)` / stale 判定 | `skill_setup.go:570-596`、`paths.go:306-327` |
|
||||
| `ReservedSkillPrefixes` / `ReservedSkillNames` 常量 | `skill_setup.go:199/205`、`paths.go:332-334` |
|
||||
| `State` 读写(state.json,见 2.4) | 新增(《迁移计划》P0b-1) |
|
||||
|
||||
脚本侧不生成代码(sh/ps1/js 三语言片段生成成本高、措辞差异大),改为
|
||||
**可解析标记块 + policy 比对**:每个脚本的清单包在
|
||||
`# DWS-AGENT-HOMES-BEGIN` / `# DWS-AGENT-HOMES-END` 注释块内,新增
|
||||
`scripts/policy/check-agent-homes-sync.sh` 提取 7 个脚本块与
|
||||
`skillhome` 导出清单(`go run ./internal/skillhome/cmd/dump` 或
|
||||
`dws skill setup --print-agent-homes` 之类调试出口)逐一比对,挂进
|
||||
`make policy`(Makefile:86-97 现有政策链)。对应《迁移计划》P0c-2。
|
||||
|
||||
### 2.3 安装逻辑单引擎化:脚本退化为 bootstrap
|
||||
|
||||
**终态形态**:每个安装脚本只做两件事——装二进制、执行
|
||||
`dws skill setup --mode <resolved> --yes`(mode 解析仍允许脚本做,因为它
|
||||
要处理自己的 env/flag;也可以更进一步把 `DWS_SKILL_MODE` 透传给 setup)。
|
||||
执行失败(二进制跑不起来、setup 非 0 退出)时,降级到**冻结的内嵌
|
||||
fallback 拷贝逻辑一档**——即今天的拷贝实现原样保留但标记"不再演进",
|
||||
语义变更只改 Go 引擎。
|
||||
|
||||
各面降级可行性分析:
|
||||
|
||||
| 面 | 二进制可得性 | 可行性 | 风险与对策 |
|
||||
|---|---|---|---|
|
||||
| install.sh | `main` 先 `install_binary` 后 `install_skills`(`install.sh:830-831`),二进制就在 `$INSTALL_DIR` | ✅ 直接调 `"$INSTALL_DIR/dws" skill setup --mode "$SKILL_MODE" --yes` | `DWS_SKILLS_ONLY=1` 时不装二进制 → 先 `command -v dws`,无则走 fallback。curl 下载不带 quarantine 属性,macOS 可直接执行。风险低 |
|
||||
| install.ps1 | 同序(`Install-Binary`:337 → `Install-Skills`:622,main 入口 `:700`) | ✅ 调 `& $installDir\dws.exe skill setup ...` | ExecutionPolicy 约束的是 .ps1 脚本本身,**子进程 dws.exe 不受其限制**;AppLocker/WDAC 环境可能拦未签名二进制——但那种环境 dws 本身也跑不了,fallback 拷贝仍必要。风险低-中 |
|
||||
| npm install.js | postinstall 已把平台二进制解到 `vendor/`(`install.js:260`)再装 skill(`:271-280`) | ✅ `execFileSync(path.join(vendorDir,'dws'), ['skill','setup','--mode',m,'--yes'])` | `npm i --ignore-scripts` 时 postinstall 整体不跑(现状如此,非新增风险);部分 CI 以 root 跑生命周期脚本权限怪异;Windows 用 `dws.exe`。风险中,fallback 必须保留 |
|
||||
| install-skills.sh | skills-only 面,不装二进制 | ✅ `command -v dws` 有则调引擎,无则 fallback | 本质是"刷新 skill"路径,有 dws 才谈得上刷新。风险低 |
|
||||
| install-event.sh | 装二进制 + 单 skill(dingtalk-event + mono dws) | ⚠️ 有条件 | `EVENT_VERSION` 可与已装二进制版本不同(尤其 `DWS_SKILLS_ONLY=1`),embed 与目标 zip 可能错配;且当前同时装 mono+multi 的语义(`:171-172`)需先按 1.2-4 收敛。**最后迁移**,迁移前先把 dingtalk-event 纳入 embed 并改成 `-s event` 调引擎 |
|
||||
| install-devapp.sh(+ps1) | 同上(dingtalk-dev) | ⚠️ 有条件 | 同 install-event。风险中-高,最后迁移 |
|
||||
| Homebrew caveats | 只打印提示 | ✅ 已是终态 | `homebrew.rb.tmpl:33-36` 现状就是"Run `dws skill setup`",无需改,是其他面的样板 |
|
||||
|
||||
配套收益:《迁移计划》P2-2(sh/ps1 的 multi 分支真装,1.5d)与 P2-3
|
||||
(install.js / install-skills.sh 加 mode 支持,1d)在单引擎方案下**大幅
|
||||
缩水**——脚本只需把 mode 透传给引擎,不再在 sh/ps1/js 里写安装逻辑。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **state.json 权威化**(《迁移计划》P0b 原样采纳):
|
||||
`~/.dws/skills/state.json` 记录 `schema_version / mode / cli_version /
|
||||
installed / agent_homes / previous`,写方只有 `dws skill setup` 与
|
||||
`dws upgrade`(脚本侧不再各自写状态——它们调引擎,引擎记账);缺失/损坏
|
||||
时按磁盘形态反推(有 `dws/` → mono;有 `dingtalk-*` → multi;都有 →
|
||||
报 drift 要求显式收敛)。
|
||||
- **`~/.dws/skills` 缓存收敛**:二选一,本文建议后者——
|
||||
1. ~~被 state 取代,删除缓存~~:激进;embed 之外的"无源码机回退源"场景
|
||||
(`skill_setup.go:442-447` 注释描述的场景)会断。
|
||||
2. **明确为 setup 回退源 + 版本戳**(采纳):缓存目录写入
|
||||
`cli_version` 戳文件(或并入 state.json 的 `cache_version` 字段);
|
||||
**写缓存收敛到 Go 侧唯一实现**(setup/upgrade 共用),install.sh /
|
||||
ps1 / js / event / devapp 的 6 份缓存写逻辑(1.1 矩阵)随 P1/P3 删除;
|
||||
upgrade 补齐 mono 缓存刷新(修 `paths.go:292-296` 只刷 multi 的不对称)。
|
||||
mono 下线版本再评估整体废弃缓存。
|
||||
|
||||
### 2.5 市场 skill 边界正式化
|
||||
|
||||
把"`dingtalk-*` 前缀与 `dws-shared` / `dws` 名称为 DWS 内置保留"从隐性约定
|
||||
变成共享常量 + 测试:
|
||||
|
||||
- `skillhome.ReservedSkillPrefixes = ["dingtalk-"]`、
|
||||
`skillhome.ReservedSkillNames = ["dws-shared", "dws"]`,替换
|
||||
`skill_setup.go:199/205` 与 `paths.go:332-334` 两份私有拷贝;所有互斥/
|
||||
stale 清理只认这两个常量。
|
||||
- 测试一:扫描 `skills/multi/` 实际目录名,断言全部命中保留规则(防新增
|
||||
产品 skill 破坏前缀约定)。
|
||||
- 测试二:市场 skill 安装路径(`skill_command.go:488-498` 的目标解析)若
|
||||
产物名命中保留名则拒绝安装——把"市场无同名前缀"从祈祷变成门禁。
|
||||
|
||||
## 3. 迁移步骤(渐进、不破坏存量用户)
|
||||
|
||||
| 阶段 | 内容 | 风险 |
|
||||
|---|---|---|
|
||||
| **P0 清单 + policy** | 建 `internal/skillhome`:AgentHomes(**补 opencode**,修 1.2-1)、布局/互斥/保留名常量;setup/upgrade 切到共享包;脚本清单包标记块;新增 `check-agent-homes-sync.sh` 进 `make policy`;删 install.sh 死代码(1.2-2) | 低。纯收敛不改行为;policy 脚本初期可能误报 → 先 warn-only 跑一个版本再转 hard-fail |
|
||||
| **P1 setup 单引擎 + 脚本降级** | setup/upgrade 安装实现合一(互斥清理、stale 语义统一为"引擎内一种",修 1.2-3);install.sh / install.ps1 / install.js / install-skills.sh 改为 bootstrap + 冻结 fallback;install-event/devapp 暂不动 | 中。bootstrap 首版要在三语言 CI 矩阵上验证"引擎失败→fallback"链路;fallback 标记冻结,防止继续演进出第 9 份拷贝 |
|
||||
| **P2 state 权威 + 缓存收敛** | state.json 读写进 skillhome,setup/upgrade 记账;缓存加版本戳、写方收敛到 Go、upgrade 补刷 mono 缓存;install-event/devapp 语义收敛(mono+multi 双装改为引擎语义)后同样 bootstrap 化 | 中。存量机器无 state → 磁盘反推逻辑必须有故障注入测试;event/devapp 版本错配场景需保留 zip 直装逃生门 |
|
||||
| **P3 删除遗留拷贝** | 删除:脚本侧 6 份缓存写、install.sh/ps1/js 内被引擎接管的安装函数(保留冻结 fallback 一档)、Go 侧 `mutualExclusionVictims` 等被 skillhome 吸收的私有拷贝;观察一个版本后评估 fallback 是否可再降档 | 低-中。每删一处先确认 policy 与测试不再引用;fallback 删除是独立决策,不与本阶段捆绑 |
|
||||
|
||||
与《迁移计划》的先后关系:本文 P0 即计划 P0c,应**最先做**;P1 与计划
|
||||
P0a/P0b 并行不冲突(引擎合一会让 P0a 的 mode-aware upgrade 少写一份代码);
|
||||
P2 吸收计划 P0b;P3 在计划 P2 切默认之后执行。
|
||||
|
||||
## 4. 明确不做
|
||||
|
||||
- **不重写成 symlink canonical**(canonical 一份 + 各 agent home 软链):
|
||||
已随生态分发通道一并评估否决,决策与实测原因见
|
||||
《迁移计划》[§7.3](skill-multi-migration-plan.md)(无版本固定、依赖
|
||||
Node/GitHub 可达、mono 会被一起发现、悟空 bundled/离线预装覆盖不了;
|
||||
其中悟空分发线已于 2026-08-05 下线,该条约束随之消失)。
|
||||
本地 symlink 模式(`setup --link`)同样维持计划 §8.2"可选/后置"定位,
|
||||
不在本方案内。
|
||||
- **不动 `dws-skills.zip` 产物布局**:zip 根 mono 副本 + `mono/` + `multi/`
|
||||
双树保持不变(`scripts/release/post-goreleaser.sh:220-248`)。
|
||||
- **不动市场 skill**:`dws skill install` 的安装语义、目标解析
|
||||
(`skill_command.go:488-498`)不变;2.5 只新增保留名拒绝门禁与测试,
|
||||
不改变既有安装行为。
|
||||
- **不删 fallback**:脚本侧内嵌拷贝逻辑降级为冻结档保留,不追求"脚本零
|
||||
拷贝"的纯净化。
|
||||
|
||||
## 5. 收益/成本与任务映射
|
||||
|
||||
### 5.1 收益/成本表
|
||||
|
||||
| 项 | 现状 | 目标 | 量化收益 |
|
||||
|---|---|---|---|
|
||||
| agent home 清单 | 15 份拷贝,已漂移(opencode) | 1 份 Go 源 + policy 比对 | 新增 agent 从"改 11 处"变"改 1 处" |
|
||||
| 安装/互斥/stale 逻辑 | ≥8 份,4 种语言,语义已分叉 | 1 个 Go 引擎 | 语义变更从 4 语言 × 7 面(本次实证 13 文件 +759 行)变 1 包 |
|
||||
| mode 解析 | 4 份,行为细节不一 | 脚本只做透传,引擎一处实现 | 非法值/默认值行为天然一致 |
|
||||
| 缓存写 | 6 份,无版本戳,只写不读 | Go 唯一写方 + 版本戳 | 缓存漂移可观测、可判废 |
|
||||
| 安装状态 | 无 | state.json 权威 | upgrade 粘性、回滚、drift 检测的前提 |
|
||||
| 市场边界 | 隐性约定,2 份前缀拷贝 | 共享常量 + 2 个测试 | 互斥清理误伤市场 skill 的风险归零 |
|
||||
| 成本 | — | P0≈1.5d,P1≈3-4d,P2≈2d,P3≈1d | 合计 7.5-8.5 人日;其中 P0/P2 与《迁移计划》P0b/P0c 重叠,净新增约 4-5 人日 |
|
||||
|
||||
### 5.2 与《迁移计划》§8 任务 ID 映射
|
||||
|
||||
| 本文阶段 | 对应计划任务 | 关系 |
|
||||
|---|---|---|
|
||||
| P0 清单 + policy | P0c-1(清单下沉共享包+补 opencode)、P0c-2(check-agent-homes-sync.sh) | 原样采纳,包名定为 `internal/skillhome` |
|
||||
| P1 引擎合一 | P0a-1(UpgradeSkillLocations mode-aware)的前置简化 | 引擎合一后 P0a-1 不复写互斥/清理逻辑 |
|
||||
| P1 脚本 bootstrap | **取代** P2-2(sh/ps1 multi 真装,1.5d)、P2-3(install.js/install-skills.sh mode 支持,1d) | 脚本不再写安装逻辑,两个任务缩水为"透传 mode + 调引擎 + 冻结 fallback",合计从 2.5d 降至 ~1d |
|
||||
| P2 state 权威 | P0b-1(state.json schema+setup 写入)、P0b-2(磁盘反推) | 原样采纳;缓存版本戳与写方收敛为本文新增 |
|
||||
| P2 event/devapp 收敛 | 新增(修 1.2-4 语义矛盾) | 依赖 P1 引擎稳定 |
|
||||
| P3 删除遗留 | 新增 | 在计划 P2 切默认之后执行 |
|
||||
| (不在本文) | P0-3 备份式安装、P0-4 请求头、P1-1 `dws skill mode`、P1-2/P2-1 默认翻转、P2-4 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
|
||||
@@ -0,0 +1,231 @@
|
||||
# Skill 能力缺口分析与补全计划
|
||||
|
||||
> 基于 `feat/skill-mode-migration` 工作区代码级盘点(2026-08-05)。本工作区已把
|
||||
> install / upgrade / setup 的默认形态翻转为 multi;本文回答两个问题:
|
||||
> **管理能力上还缺什么**(Part 1)、**multi 内容是否覆盖 mono**(Part 2),
|
||||
> 并给出优先级排序的实施清单(Part 3)。
|
||||
> 任务 ID 复用 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §8.2
|
||||
> (P0a/P0b/P0c/P0-3/P0-4/P1-1/P1-2/P2-x/W4);新增项以 `P1-3`、`C1–C7` 编号。
|
||||
> 机制背景见 [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
- **管理面**:默认翻转已落地(setup/四个安装脚本/upgrade 识 multi),但生命周期
|
||||
能力仍缺 6/8 项:无状态(state.json)、无备份回滚、无卸载、无故障恢复、
|
||||
upgrade 不按已装清单做增量、市场/内置边界靠前缀约定。当前形态 = "能装上
|
||||
multi,但装成什么样、出了事怎么退,全靠运气"。
|
||||
- **内容面**:multi 对产品参考的覆盖**总体是 mono 超集**(chat/event/dev/sheet
|
||||
均更详细),但 mono 有 **4 块全局能力在 multi 完全缺失**(recovery 闭环、
|
||||
确认门禁协议、Schema 渐进查询教学、LICENSE/NOTICE),另有 1 个脚本
|
||||
(`report_inbox_today.py`)未迁移;multi 自身还有 6 项内部不一致(死链、
|
||||
orphan 脚本、漏改 EXPERIMENTAL 文案等)。
|
||||
- **工作量**:管理面 P0–P2 剩余 ≈ 10.5–13 人日;内容补全 C1–C7 ≈ 3.5–4 人日;
|
||||
合计 ≈ 14–17 人日,与 plan §8.1 的 13–17 人日口径一致(内容项是新增量)。
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — 管理能力缺口(skill 生命周期管理)
|
||||
|
||||
### 1.1 现状盘点(本工作区实际状态)
|
||||
|
||||
**`dws skill setup`(内置 skill 安装)**
|
||||
|
||||
| 能力 | 状态 | 锚点 |
|
||||
|---|---|---|
|
||||
| mode 选择 | mono / multi;无 `--mode` 时 TTY 交互(multi 为默认项)、非 TTY 默认 multi | `internal/app/skill_setup.go:343-376` |
|
||||
| 按产品挑选 | `-s/--skill`、`-x/--exclude` 互斥;`dws-shared` 强制包含;未点名的已有 `dingtalk-*` 保留(additive) | `skill_setup.go:101-102`、`254-318`、`209-226`、`640-670` |
|
||||
| `--dry-run` | 有,预览 mode/来源/目标/子 skill,不写文件(root 持久 flag 注入,`internal/app/flags.go:43`) | `skill_setup.go:156-167` |
|
||||
| 源 | `--source` / `DWS_SKILL_SOURCE` 显式覆盖(失败不回退)→ 默认 embed(与二进制同版) | `internal/app/skill_setup_embed.go:50-58`、`skills_embed.go:28` |
|
||||
| 目标 | 16 个 agent home,父目录门控;`--target all` 不含 opencode,但命名 target 含(不对称) | `skill_setup.go:20-37`、`509-522` vs `internal/app/skill_command.go:129` |
|
||||
| 互斥清理 | 装 mono 删 `dingtalk-*`+`dws-shared`,装 multi 删 `dws/`;**best-effort,失败仅 warning 继续装** | `skill_setup.go:570-608` |
|
||||
| 备份/记账 | **无**。`RemoveAll` 直删,不写任何状态 | `skill_setup.go:616`、`655` |
|
||||
|
||||
**`dws skill search / get / install`(市场 skill)**
|
||||
|
||||
- 子命令全集仅 `get / install / search / setup`(+ 隐藏的 find/add 兼容提示),
|
||||
**无 status / mode / remove / rollback**(`skill_command.go:202-209`)。
|
||||
- `install` 解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念、
|
||||
无记账、无覆盖保护(`skill_command.go:378-443`、`653-672`)。
|
||||
|
||||
**`dws upgrade`(skill 刷新)**
|
||||
|
||||
- `LocateSkillsRoot` **优先 zip 内 `multi/`**(`internal/upgrade/paths.go`,
|
||||
接线于 `internal/app/upgrade.go`);`UpgradeSkillLocations` **包驱动**:有
|
||||
multi 树则始终 multi 刷新,否则 legacy mono 回退(不做磁盘粘性)。
|
||||
- multi 路径:平铺刷新 `<agent>/dingtalk-*`+`dws-shared`,清 `dws/` 残留与过期
|
||||
`dingtalk-*`,best-effort 刷 `~/.dws/skills/multi`(sibling `mono/` 存在时
|
||||
亦刷 mono 缓存)。
|
||||
- **有 multi 包时存量 mono 一次性迁 multi**(2026-08-05 owner:升级不做粘性;
|
||||
非运行时 mode-switch 产品,无确认/备份/state)。
|
||||
- skill 安装发生在**二进制替换之后**,skill 失败时二进制已换 → 半升级态。
|
||||
`dws upgrade --rollback` 只回滚二进制。
|
||||
|
||||
**安装脚本(7 面)**
|
||||
|
||||
| 面 | 本工作区状态 |
|
||||
|---|---|
|
||||
| `scripts/install.sh` | 已默认 multi 且**真装**(`install_multi_skills_to_homes`),mono 为 opt-in;`rm -rf` 直删无备份 |
|
||||
| `scripts/install.ps1` | 同上(Windows) |
|
||||
| `scripts/install-skills.sh` | 已加 `DWS_SKILL_MODE`(默认 multi)+ multi 安装 |
|
||||
| `build/npm/install.js` | 已加 `installMultiSkillsToHomes`(互斥清理 + 平铺) |
|
||||
| Homebrew formula | 不铺 agent home,caveats 引导 `dws skill setup` |
|
||||
| `scripts/install-event.sh` / `install-devapp.sh` | 单 skill 专项语义,缓存 `multi/dingtalk-event` / `multi/dingtalk-dev` 子集 |
|
||||
| 共同缺口 | **均不写 state.json、均无备份**;agent home 清单仍是 sh/ps1/js/Go 多份手写(`paths.go:23-52` 的 keep-in-sync 注释约定,无门禁) |
|
||||
|
||||
### 1.2 缺口表(对照完整生命周期)
|
||||
|
||||
严重度:🔴 高(可致数据丢失/双份派发/半装态)|🟡 中(能力缺失但有绕行)|🟢 低(体验项)
|
||||
|
||||
| # | 生命周期项 | 现状 | 严重度 | 补全设计(复用 plan §8 ID) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **status 查询** | **无**。无 state.json、无 `dws skill mode/status`;判断 mode 只能人工看磁盘形态(`dws/` vs `dingtalk-*`) | 🔴 | **P0b-1** 新增 `~/.dws/skills/state.json`(schema_version/mode/cli_version/installed/agent_homes/previous),setup 与安装脚本写入;**P0b-2** 缺失/损坏时磁盘形态反推,双形态并存 → drift 报错;**P1-1** `dws skill mode`(status 子命令展示 mode/版本/已装列表/上次切换/备份) |
|
||||
| 2 | **切换** | 有(`setup --mode`),但**无状态、无记账**:切完不留 previous,互斥清理失败仅 warning 继续装(`skill_setup.go:600-608`)→ 可能 mono+multi 双份共存、Agent 双份派发 | 🔴 | **P0b** 记账(含 `previous`);**P0-3** 把「清理失败继续装」改为「失败整体回滚」;**P1-1** `mode set <mode>` 复用 setup 安装实现 + 备份 + 记账,成功后提示重启 AI 工具 |
|
||||
| 3 | **回滚** | **无备份**。setup/upgrade/安装脚本全部 `RemoveAll`/`rm -rf` 直删(`skill_setup.go:616,655`、`paths.go:181,249,307`、`install.js` `fs.rmSync`);`upgrade --rollback` 只回二进制 | 🔴 | **P0-3** 备份式安装:`RemoveAll` → `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/`,保留最近 2 份,任一 home 失败自动恢复并非 0 退出;**P1-1** `mode rollback` 一条命令回到 `state.previous` |
|
||||
| 4 | **版本对齐** | embed 天然同版(setup 默认源,`skill_setup_embed.go:50-58`)✓;但 `~/.dws/skills` 缓存**只写不读**(仅 legacy 回退候选,`skill_setup.go:445-447`),upgrade 只刷 multi 缓存不刷 mono(`paths.go:290-296`),漂移不可见 | 🟡 | **P0b-1** state.json 记 `cli_version` 使漂移可见;**P0a-1** upgrade 按 mode 同步刷新对应缓存(或评估废弃缓存,plan §6 风险表末行) |
|
||||
| 5 | **卸载** | **无 remove**。skill 子命令仅 get/install/search/setup(`skill_command.go:202-209`);用户只能手动删目录,且不知道该删哪些(16 个 home × N 个 skill) | 🟡 | **新增 P1-3** `dws skill remove`:按 state.json 的 `agent_homes`×`installed` 精确删除内置 skill(`dingtalk-*`+`dws-shared`+`dws/`),不动市场 skill;`--dry-run` 预览(依赖 P0b) |
|
||||
| 6 | **局部更新** | setup 侧 additive 语义完整(`-s/-x`,未点名保留);但 **upgrade 增量语义未按 `state.installed`**:`UpgradeSkillLocations` 用 zip 内 bundle 全集刷新(`paths.go:135-137`、`339-358`),用户 `-x` 排除过的 skill 会被升级装回来 | 🔴 | plan §8.3-1 已定死语义(以 `state.installed` 为准增量刷新、未装不补装);**P0b-1** 先落 `installed` 列表,**P0a-1** `UpgradeSkillLocations(dir, mode)` 按其过滤 |
|
||||
| 7 | **故障恢复** | **无**。setup 清理/拷贝失败仅 warning 或按 home 跳过,留半装态;upgrade 的 skill 失败发生在二进制替换之后(`upgrade.go:593-611`),半升级态无自动恢复、无修复命令 | 🔴 | **P0-3** 备份式安装 + 失败整体回滚(改变「warning 继续装」语义,需同步改 `skill_setup_full_coverage_test.go` 多处断言,plan §8.3-2);**P0b-2** drift 检测给显式收敛指令;**P1-1** `mode rollback` |
|
||||
| 8 | **市场 vs 内置边界** | **隐性前缀约定**:互斥清理按 `dingtalk-`/`dws-shared` 名称扫描(`paths.go:332-334`、`skill_setup.go:581`),无 SKILL.md frontmatter 校验 —— 市场 skill 若同名前缀会被误删;反向地,市场 `install` 解压无保护,可覆盖内置 `dingtalk-*`(`skill_command.go:653-672`) | 🟡 | plan §6 风险行:清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集并写成测试(落入 **P0-3** 的清理改造);长期由 state.json 的 `installed` 清单取代前缀扫描(P0b 后续) |
|
||||
|
||||
**附:本工作区已完成项**(不再列入缺口):setup 默认 multi(P2-1)、
|
||||
install.sh/ps1 真装 multi(P2-2)、install-skills.sh/install.js mode 支持
|
||||
(P2-3)、README×2 与 dingtalk-skill 文案(P2-4 部分)、upgrade 识别并刷新
|
||||
multi 包(P0a 的布局识别一半)。**尚未做**:P0c 清单收敛、P0b state.json、
|
||||
P0-3 备份、P0a 的 mode-aware 一半、P0-4 请求头、P1-1 mode 命令、P1-2 beta 轨。
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — 内容能力对等(mono vs multi)
|
||||
|
||||
### 2.1 树对比总览
|
||||
|
||||
| 维度 | mono | multi |
|
||||
|---|---|---|
|
||||
| 规模 | 152 文件 / ≈2.5 MB | 244 文件 / ≈3.3 MB(19 个 `dingtalk-*` + `dws-shared`) |
|
||||
| 入口 | 单 `SKILL.md`(332 行,含全局路由/危险表/Schema 教学) | 每产品一个 `SKILL.md` + `dws-shared` 全局契约(90 行) |
|
||||
| 全局参考 | `references/` 10 项(intent-guide、global-reference、url-patterns、error-codes、capability-limits、channel-login、field-rules、recovery-guide、best_practices/、products/) | `dws-shared/references/` 9 项 + 各产品 skill 自带 |
|
||||
| 脚本 | `scripts/` 37 个 | 10 个 skill 带 `scripts/` 共 55 个 |
|
||||
| 最佳实践 | `best_practices/` 01–11 + lite + `_common` | 按产品分发(01→chat … 11→minutes),`_common` 与 lite 入 `dws-shared` |
|
||||
|
||||
### 2.2 产品参考覆盖核对
|
||||
|
||||
逐产品比对结论:**multi 覆盖 mono 全部产品参考,且多数为超集**。
|
||||
|
||||
| mono 产品参考 | multi 对应物 | 结论 |
|
||||
|---|---|---|
|
||||
| aitable.md + aitable/(20)+ aitable-record-ops | `dingtalk-aitable`(同 20 子章节 + field-rules + 06-data-analytics) | ✅ 超集 |
|
||||
| chat.md + chat-emoji-list | `dingtalk-chat`(+ chat/ 5 个子章节) | ✅ 超集 |
|
||||
| calendar / contact / doc+doc/ / drive / mail / minutes / todo / wiki / aisearch / hrbrain / pat / markdown | 同名 `dingtalk-*` skill | ✅ 覆盖(doc 侧 mono 的 doc-file-ops/doc-list/doc-permission/doc-search 四篇已迁移为 `dingtalk-doc/references/doc.md:202-207` 的 drive/wiki 迁移表,属刻意重构) |
|
||||
| event.md(222 行) | `dingtalk-event`(event-im.md 399 行 + 完整订阅治理契约) | ✅ 超集 |
|
||||
| dev.md(212 行) | `dingtalk-dev`(12 篇 reference 共 584 行) | ✅ 超集(结构重组) |
|
||||
| oa / attendance / report / sheet / ding / devdoc / agoal | `dingtalk-misc` 对应 reference(sheet 多 3 篇:comment/formula/version) | ✅ 超集 |
|
||||
| simple.md(devdoc+oa 合集 + 意图判断 + 上下文传递表) | 已拆入 `dingtalk-misc/references/oa.md:309,425` 与 `devdoc.md:15,19` | ✅ 覆盖 |
|
||||
| 多组织/多账号(SKILL.md:64-70 一节) | `dingtalk-profile` 整个 skill | ✅ 超集 |
|
||||
| 意图决策树(SKILL.md:101-123) | `dws-shared/references/intent-guide.md`(536 行 ≥ mono 488 行)+ `routing.md` | ✅ 覆盖 |
|
||||
| best_practices 01–11 / lite / _common | 按产品分发 + `dws-shared/references/best_practices/_common/` | ✅ 覆盖 |
|
||||
| url-patterns / capability-limits / channel-login / error-codes | `dws-shared/references/` 同名 | ✅ 覆盖 |
|
||||
| field-rules.md(mono 全局位) | `dingtalk-aitable/references/field-rules.md` | ✅ 合理下沉(内容即 AI 表格字段规则) |
|
||||
|
||||
### 2.3 不对等项清单(mono 有、multi 无)
|
||||
|
||||
| # | 缺失项 | mono 锚点 | multi 现状证据 | 严重度 | 补齐方式(承载方) |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | **Recovery 闭环**(recovery-guide.md + global-reference §Recovery + 错误处理第 2 步) | `skills/mono/SKILL.md:296,311`、`references/recovery-guide.md`、`global-reference.md:62` | multi 全树 `RECOVERY_EVENT_ID` 零引用;`dws-shared/SKILL.md:83-89` 错误最短路径无 recovery;`dingtalk-dev/SKILL.md:124` 指向「root dws / dws-shared 的错误处理」→ **断链** | 🔴 | **dws-shared**:新增 `references/recovery-guide.md`(从 mono 移植)、SKILL.md 错误最短路径加 recovery 步、`global-reference.md` 补 Recovery 节(→ C1) |
|
||||
| M2 | **确认门禁协议 + 全局危险操作表** | `skills/mono/SKILL.md:137-187`(危险操作表 + 确认流程 + `confirmation_required` 识别与重试协议) | multi 仅 aitable/doc/misc 三个 SKILL.md 有产品级危险表;`confirmation_required` / 「确认门禁」全树零命中;`dws-shared/SKILL.md:37-38` 只有一行泛化规则 | 🔴 | **dws-shared**:全局确认门禁协议(识别 `confirmation_required`、原始命令追加 `--yes` 重试、`--dry-run` 预览、禁止管道喂答案)+ 危险操作索引指向各产品表(→ C2) |
|
||||
| M3 | **Schema 渐进查询教学** | `skills/mono/SKILL.md:198-292`(≈95 行:四层查询、`--compact`/`--all` 边界、字段速查、Schema/Help/业务数据边界表、漂移处理) | 各产品 SKILL.md 仅点状提及 leaf Schema(17 处);`dws-shared/references/global-reference.md:79-89`「命令自省」只有 `--help` | 🟡 | **dws-shared**:新增 Schema 渐进查询章节(改写为 multi 语境,链入渐进加载表)(→ C3) |
|
||||
| M4 | **scripts/report_inbox_today.py** | `skills/mono/scripts/report_inbox_today.py` | multi 无(misc 仅有 `report_received_today.py`);mono 文档也未引用它 | 🟢 | **dingtalk-misc**:先验证脚本仍可用 → 迁入 `scripts/` 并在 `report.md` 引用;不可用则连同 mono 侧一起删(→ C5) |
|
||||
| M5 | **LICENSE / NOTICE** | `skills/mono/LICENSE`、`NOTICE` | multi 20 个 skill 均无 | 🟡 | 每个 multi skill 根复制两份(或 release 打包期注入,`scripts/release/post-goreleaser.sh`)(→ C7) |
|
||||
| M6 | **aiapp 意图路由** | `skills/mono/SKILL.md:76,101`(产品表 + 决策树有 aiapp 行,但目标 `aiapp.md` 不存在 —— mono 自身死链) | multi 全树无 aiapp 路由/文档;仅 orphan 脚本 `dingtalk-misc/scripts/aiapp_create_and_poll.py` | 🟡 | 决策:**dws-shared/routing.md** + **dingtalk-misc** 产品索引补 aiapp 行并新建 reference,或明确下线该能力并清掉 mono 死链与 orphan 脚本(→ C5) |
|
||||
|
||||
### 2.4 multi 自身不一致项(不阻塞对等结论,但阻塞「multi 可独当一面」)
|
||||
|
||||
| # | 问题 | 证据 | 处理 |
|
||||
|---|---|---|---|
|
||||
| X1 | 16 个 orphan 脚本无任何文档引用(yida×13、finance×2、aiapp×1);`dws-shared/references/routing.md` 把「宜搭」路由到 dingtalk-misc,但 misc 产品索引无 yida 行、无 yida reference | `skills/multi/dingtalk-misc/scripts/`;`dingtalk-misc/SKILL.md:20-32` | 补文档(misc 产品索引 + reference)或移出发布包(→ C5) |
|
||||
| X2 | 死链:`dingtalk-chat/SKILL.md:131` 引用 `scripts/extract_media_id.py`,文件不存在(mono 也无) | 同上 | 补脚本或删引用(→ C4) |
|
||||
| X3 | 死链:`dws-shared/references/routing.md:22` 指向 `dingtalk-misc/references/markdown.md`,实际在 `dingtalk-markdown` | 同上 | 改指 `../../dingtalk-markdown/SKILL.md`(→ C4) |
|
||||
| X4 | `dingtalk-event/SKILL.md` 缺 `dws-shared` PREREQUISITE 与 `metadata:` 块(其余 19 个 skill 均有) | `skills/multi/dingtalk-event/SKILL.md:1-4` | 补齐(→ C4) |
|
||||
| X5 | 4 个 skill 仍是 🧪 EXPERIMENTAL + 「生产优先 mono」文案,与本工作区已翻转的默认矛盾(dingtalk-skill 已改,这 4 个漏改) | `dingtalk-profile/SKILL.md:15`、`dingtalk-hrbrain:15`、`dingtalk-markdown:15`、`dingtalk-pat:15` | 统一下调文案(→ C4,即 plan P2-4 剩余量) |
|
||||
| X6 | `<!-- SAFETY_PREAMBLE_INJECT -->` 标记存在于 5 个 SKILL.md,但仓库内无注入器 | `dingtalk-{pat,hrbrain,markdown,skill,profile}/SKILL.md` | 明确注入方(仓外流程则写注释)或移除标记(→ C4) |
|
||||
|
||||
### 2.5 测试与政策门覆盖
|
||||
|
||||
| 门面 | 覆盖 | 缺口 |
|
||||
|---|---|---|
|
||||
| `test/skill_tests.md` 覆盖表(40-54 行) | 13 产品 ≈256 用例 | 12/13 行引用 **mono 路径**(`references/products/...`),仅 dev 指 multi;`workbench` 行指向不存在的 `workbench.md`(54 行,mono/multi 均无);`devdoc` 行指 `simple.md`(47 行,multi 无此文件);未覆盖 doc/drive/mail/minutes/oa/sheet/wiki/aisearch/hrbrain/markdown/pat/profile/skill。multi 默认后需按 multi 路径重写并补产品(→ C6) |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | 锁 `dingtalk-chat/SKILL.md` ≤14000B + shortcut 区块不膨胀 + mono SKILL.md 不含「充分阅读产品参考文件」回归(9-38 行);`gen_skill_shortcut_sections.py --check` 同时写 mono 与 multi 的 shortcut 区块 | mono 下线判据(plan §5-4)要求先替代对 `skills/mono/SKILL.md` 的依赖 |
|
||||
| `make skill-command-integrity`(`check-skill-commands.sh` → `test/skill_static/skill_static_test.go:119-133`) | 静态校验 mono+multi 两树文档中的命令与 flag | 覆盖 OK;X2/X3 类 reference 死链不在其校验面 |
|
||||
|
||||
**Part 2 结论**:multi **没有**覆盖 mono 的全部能力 —— 产品参考层面是超集,
|
||||
但全局能力缺 M1–M3 三块(recovery / 确认门禁 / Schema 教学),加 M4–M6 三个
|
||||
小项;另有 X1–X6 六项 multi 内部不一致。补齐全部落在 `dws-shared`(M1/M2/M3)、
|
||||
`dingtalk-misc`(M4/M6/X1)、各产品 skill(X2/X4/X5)与打包流程(M5)。
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — 优先级排序实施清单
|
||||
|
||||
> 估时以 1 名熟悉本仓库的工程师计(人日),口径同 plan §8。
|
||||
> 「状态」列:✅ 本工作区已完成|🚧 部分完成|⬜ 未做。
|
||||
|
||||
### 3.1 P0 — 先堵会丢数据/双份派发的洞(≈6.5–8.5d)
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P0c-1 | ⬜ | agent home 清单下沉共享包,补 opencode 不对称 | 新 `internal/skillhome`(或 `internal/upgrade` 导出):合并 `paths.go:35-52` `knownSkillDirs` + `skill_setup.go:20-37` `skillSetupAgentHomes` + `skill_command.go:119-141` `agentSkillPaths`;setup/upgrade/测试共用 | 0.5d | — |
|
||||
| P0c-2 | ⬜ | 清单同步门禁 | 新 `scripts/policy/check-agent-homes-sync.sh`(进 `make policy`);install.sh:ps1:install.js:install-skills.sh 清单改可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | ❌ CANCELLED | state.json schema + 写入 | 2026-08-05:无运行时切换,不写 state.json | — | — |
|
||||
| P0b-2 | ❌ CANCELLED | 磁盘形态反推作 state 兜底 | 无 sticky / 无 state;upgrade 按包刷 multi | — | — |
|
||||
| P0-3 | ❌ CANCELLED | 备份式安装 + 失败整体回滚 | 2026-08-05:随切换产品线取消 | — | — |
|
||||
| P0a-1 | ✅ | upgrade 包驱动 multi | `UpgradeSkillLocations`:有 multi→始终 multi(含 mono 盘一次性迁移);legacy 无 multi→mono;不读 state | — | — |
|
||||
| P0a-2 | ✅ | force-multi 集成 / E2E | `paths_multi_test.go`(`MonoDiskMigratesToMulti` 等)+ `upgrade_skill_multi_e2e_test.go` | — | — |
|
||||
| P0-4 | ❌ CANCELLED | `x-dws-skill-mode` 请求头 | owner 决策移除 | — | — |
|
||||
|
||||
### 3.2 P1 — 生命周期命令 + 内容补全(≈7–7.5d,两条线可并行)
|
||||
|
||||
**管理命令线**
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P1-1 | ❌ CANCELLED | `dws skill mode` status/set/rollback/--dry-run | 2026-08-05:无运行时模式切换产品 | — | — |
|
||||
| P1-3 | ⬜ | `dws skill remove`(新增,缺口 #5) | 按磁盘/约定前缀精确删内置 skill(**不**依赖已取消的 state.json);`--dry-run` 预览 | 1d | — |
|
||||
|
||||
**内容补全线**(对应 Part 2 编号)
|
||||
|
||||
| ID | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| C1 | recovery 闭环进 multi(M1) | 新 `skills/multi/dws-shared/references/recovery-guide.md`;`dws-shared/SKILL.md:83-89` 错误最短路径加 recovery 步;`dws-shared/references/global-reference.md` 补 Recovery 节;`dingtalk-dev/SKILL.md:124` 断链改指 | 0.5d | — |
|
||||
| C2 | 确认门禁协议 + 危险操作索引(M2) | `dws-shared/SKILL.md` 增「确认门禁」节(移植 mono `SKILL.md:170-187` 协议)+ 危险操作索引表指向各产品 SKILL.md | 0.5d | — |
|
||||
| C3 | Schema 渐进查询教学(M3) | `dws-shared/SKILL.md` 渐进加载表加一行 + 新 `references/schema-usage.md`(改写 mono `SKILL.md:198-292`) | 0.5d | — |
|
||||
| C4 | multi 一致性修复(X2/X3/X4/X5/X6 = plan P2-4 剩余量) | `dws-shared/references/routing.md:22` 改指 dingtalk-markdown;`dingtalk-chat/SKILL.md:131` 死链处置;`dingtalk-event/SKILL.md` 补 PREREQUISITE+metadata;`dingtalk-{profile,hrbrain,markdown,pat}/SKILL.md:15` EXPERIMENTAL 文案下调;SAFETY_PREAMBLE_INJECT 标记处置 | 0.5d | — |
|
||||
| C5 | orphan 脚本与缺失产品处置(X1/M4/M6) | `dingtalk-misc/SKILL.md:20-32` 产品索引补 yida/finance/aiapp 行 + 新 reference(或把 16 个脚本移出发布包);`report_inbox_today.py` 验证后迁入或删除;aiapp 路由决策落 `dws-shared/references/routing.md` | 0.5–1d | — |
|
||||
| C6 | skill_tests.md multi 化 + 扩产品 | `test/skill_tests.md:40-54` 覆盖表改 multi 路径;修 workbench(54 行)/devdoc(47 行)死链;补 doc/drive/mail/minutes/oa/sheet/wiki 用例 | 1d | C1–C5(路径稳定后) |
|
||||
| C7 | LICENSE/NOTICE 进 multi(M5) | 20 个 `skills/multi/*/LICENSE|NOTICE`(或 `scripts/release/post-goreleaser.sh` 打包期注入) | 0.25d | — |
|
||||
|
||||
### 3.3 P2 / W4 — 收尾(≈1d)
|
||||
|
||||
| ID | 状态 | 任务 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P1-2 | ⬜ | beta 轨默认切 multi(版本门控;本工作区已全量切,可选择保留全量或回退为 beta 先行) | 0.5–1d | P1-1 |
|
||||
| P2-1 ~ P2-3 | ✅ | setup / install.sh / install.ps1 / install-skills.sh / install.js 默认 multi | — | 已完成 |
|
||||
| P2-4 | 🚧 | 文案翻转:README×2、dingtalk-skill 已改 ✅;4 个 EXPERIMENTAL 漏改 → 并入 C4 | — | — |
|
||||
| W4 | ⬜ | mono deprecation 警告(不删代码)+ mono 下线判据第 4 条(替代 `check-skill-context-budget.sh:10,34-38` 对 mono 的依赖) | 0.5d | P2、C6 |
|
||||
|
||||
### 3.4 依赖图与排期建议
|
||||
|
||||
```text
|
||||
P0c-1 ─► P0c-2
|
||||
P0b-1 ─► P0b-2 ─┐
|
||||
P0-3 ───────────┼─► P0a-1 ─► P0a-2 ─► P1-1 ─► P1-3
|
||||
P0b-1 ──────────┴─► P0-4
|
||||
C1…C5(互相独立,可与 P0 并行)─► C6
|
||||
```
|
||||
|
||||
- **W1**:P0c-1 → P0b-1/P0b-2 → P0-3 → P0a-1/P0a-2(管理面止血);并行 C1–C4(内容高危项)。
|
||||
- **W2**:P0-4、P1-1、P1-3;并行 C5、C7。
|
||||
- **W3**:C6(内容面收口)+ P1-2 beta 决策;`make policy` 全绿。
|
||||
- **W4**:mono deprecation + 观察(plan §4 原节奏不变)。
|
||||
|
||||
合计:P0 ≈ 6.5–8.5d + P1 管理 ≈ 3–3.5d + 内容 C1–C7 ≈ 3.5–4d + P2/W4 ≈ 1d
|
||||
= **14–17 人日**。砍法同 plan §8.4:内容线可砍 C3/C7(教学与法律文件可后置),
|
||||
管理线不可砍 P0b/P0-3/P0a(缺了就是现在这副「能装不能管」的样子)。
|
||||
@@ -0,0 +1,146 @@
|
||||
# Skill 分发与消费机制调研(含 lark-cli 对标)
|
||||
|
||||
> 配套方案:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> 调研日期:2026-08-04,基于 `feat/skill-mode-migration` 工作区代码级梳理 +
|
||||
> lark-cli(larksuite/cli@main)公开仓库调研。
|
||||
|
||||
## 1. DWS 分发链路(源树 → 制品 → 渠道)
|
||||
|
||||
```
|
||||
skills/mono/ ─┬─ go build ──► embed.FS(skills_embed.go:28)
|
||||
skills/multi/ ┘ │ dws skill setup 默认源
|
||||
│
|
||||
└─ post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
布局:zip 根 = mono 副本(向后兼容)+ mono/ + multi/
|
||||
│
|
||||
┌───────┬───────┼────────┬─────────┬──────────┐
|
||||
GitHub Gitee OSS npm Homebrew 专项脚本
|
||||
Release (镜像) (只发 tarball (cellar install-event/
|
||||
│ │ 不读) │ 不铺agent) install-devapp
|
||||
│ │ │
|
||||
install.sh/ps1 postinstall
|
||||
install-skills.sh install.js
|
||||
│ │
|
||||
├─► 各 agent home ◄────┤ 永远 mono:<agent>/dws/
|
||||
└─► ~/.dws/skills/{mono,multi} 缓存
|
||||
```
|
||||
|
||||
渠道清单(7 个安装/分发面):
|
||||
|
||||
| 面 | skill 行为 | mode 概念 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | 装 mono 到 agent homes + 双缓存;选 multi **只打印提示、连缓存都跳过** | 有(半残) |
|
||||
| `scripts/install.ps1` | 同 install.sh(Windows) | 有(半残) |
|
||||
| `scripts/install-skills.sh` | 只装 mono,缓存 mono+multi | 无 |
|
||||
| `build/npm/install.js` | 永远 mono;缓存 mono+multi | 无 |
|
||||
| Homebrew formula | 整包 zip 进 cellar,不铺 agent、不写缓存,caveats 提示手动 setup | 无 |
|
||||
| `scripts/install-event.sh` | 装 mono + 缓存 `multi/dingtalk-event`;多 `.config/opencode/skills` | 无 |
|
||||
| `scripts/install-devapp.sh` | 缓存 `multi/dingtalk-dev` | 无 |
|
||||
|
||||
产物事实:`dws-skills.zip` 已含 mono/multi 双树,**切 multi 不需要改 release 产物**。
|
||||
|
||||
## 2. DWS 消费链路(源 → agent 目录)
|
||||
|
||||
### 2.1 `dws skill setup`(`internal/app/skill_setup.go`)
|
||||
|
||||
- 源优先级:`--source` / `DWS_SKILL_SOURCE`(失败不回退)→ **embed 默认**
|
||||
(`skill_setup_embed.go:50-58`);legacy 候选(exe 旁/cwd/`~/.dws/skills`)
|
||||
仅在绕过 wrapper 直连时才走。
|
||||
- 目标:`skillSetupAgentHomes` 16 个 agent home,父目录门控(i=0 `.agents`
|
||||
无条件,其余需 `~/.claude` 这类父目录存在)。`--target all` **不含
|
||||
opencode**,但 `agentSkillPaths` 命名 target 含(不对称,测试只锁单向)。
|
||||
- 布局:mono → `<agent-home>/dws/`;multi → `<agent-home>/` 平铺兄弟目录。
|
||||
- 互斥清理:装 mono 删 `dingtalk-*`、装 multi 删 `dws/`;**best-effort,
|
||||
失败仅 warning 继续装**(`cleanupMutualExclusion:611-620`),无备份。
|
||||
- multi 过滤:`-s/--skill` 与 `-x/--exclude` 互斥;`dws-shared` 强制包含;
|
||||
未点名的已有 `dingtalk-*` 保留(additive)。
|
||||
|
||||
### 2.2 `dws upgrade`(`internal/upgrade/paths.go`)
|
||||
|
||||
- `LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations` 只写
|
||||
`<agent>/dws/`:**不识 multi、不清理 `dingtalk-*`、不更新 `~/.dws/skills`**。
|
||||
- 后果:multi 用户升级后 **mono + multi 共存**,Agent 双份派发。
|
||||
- `--rollback` 只回滚二进制,不回滚 skill。
|
||||
|
||||
### 2.3 市场 skill(`dws skill install`)
|
||||
|
||||
解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念;互斥清理按
|
||||
`dingtalk-*` 前缀扫描,依赖"市场无同名前缀"隐性约定。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **无任何已安装模式状态**:`~/.dws/` 有 auth/backups/cache,无 install
|
||||
manifest;判断 mode 只能看磁盘形态。
|
||||
- `~/.dws/skills` 缓存事实**只写不读**(默认 setup 走 embed),upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
|
||||
## 3. lark-cli 分发机制(larksuite/cli@main)
|
||||
|
||||
npm 包 `@larksuite/cli` 是薄壳(`files` 仅 install.js / install-wizard.js /
|
||||
run.js / checksums.txt):
|
||||
|
||||
- **二进制**:postinstall 按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发);镜像链 GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底;host allowlist + checksum 双保险;`run.js` 缺二进制自动补下,
|
||||
Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:不进 npm 包、不进二进制。repo 根 `skills/` 即事实源,由生态
|
||||
安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)安装;
|
||||
wizard 首选 `https://open.feishu.cn` 直链,GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install` →
|
||||
install-wizard.js 串联 4 步(npm 全局装/升级 → skills → config init →
|
||||
auth login),每步幂等(`skills ls -g` 检测 `lark-*` 已装则跳过);
|
||||
非 TTY 降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 提供的能力:
|
||||
|
||||
- 76 个 agent 目录清单生态维护,自动探测;project/global 双 scope;
|
||||
- **symlink canonical(推荐)或 copy**;symlink 下升级 = 更新一处;
|
||||
- `list / find / update / remove` 全生命周期;**skill 升级由
|
||||
`npx skills update` 承担,lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
## 4. 对标:DWS vs lark-cli
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/`(源码即事实源) | zip + embed + 5 处拷贝缓存 |
|
||||
| 安装器 | 1 个(生态工具) | 7 个自维护脚本,已漂移 |
|
||||
| 落盘 | symlink 单点更新 | 全量 copy,升级重写 16 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,不识 multi |
|
||||
| mono/multi | 不存在此问题(天生多 skill) | 互斥/切换/状态全自建 |
|
||||
| npm 包 | 薄壳运行时下载 | 全平台 archive + zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS 只发不读 |
|
||||
|
||||
## 5. "分发外包给生态,自己只维护源码目录"是什么
|
||||
|
||||
职责切分:skill 的分发/安装/升级/卸载交给生态标准化工具(`npx skills`,
|
||||
"agent skill 界的 npm"),DWS 只保证 repo 里 `skills/` 符合 agentskills.io
|
||||
规范。它消掉的正是 DWS 现在自维护的四块问题:
|
||||
|
||||
1. **N 份事实源**(zip 三拷贝 + embed + 缓存 + 16 home)→ 只剩 repo 目录一份;
|
||||
2. **7 个自写安装器** → 零个,agent 清单别人维护(新 agent 自动支持);
|
||||
3. **自建升级/切换/回滚**(UpgradeSkillLocations、互斥清理、方案中的
|
||||
state.json/备份/`dws skill mode`)→ symlink 模式更新 canonical 一处;
|
||||
4. **lark-cli 因此根本没有 mono/multi 之争**,也没有 P0 要修的那些 bug。
|
||||
|
||||
### 代价与前提(不是免费午餐)
|
||||
|
||||
| 风险 | 说明 | 对策 |
|
||||
|---|---|---|
|
||||
| 版本错配 | DWS skill 从 Cobra 树生成,与 CLI 版本强耦合;embed 天然同版,生态安装从主分支拉可能错配。**已实测**:`skills add`(v1.5.21)无 tag/ref 版本固定参数,`@` 后接的是 skill 名而非 git ref;唯一的可复现机制是 project 级 `skills-lock.json`(experimental_install) | 短期:发布说明引导"skill 随 CLI 升级(`skills update`)";中期:release 时推一个 `release/vX.Y.Z` 镜像分支或专用 skills 镜像仓供按版本安装;或接受错配(skill 内容为文档,错配成本=提到不存在的命令) |
|
||||
| 网络可达 | 依赖 npx + GitHub;大陆/内网现靠 Gitee fallback | `skills` CLI 支持任意 git URL,Gitee 镜像仓兜底,链路需验证 |
|
||||
| 离线/打包场景 | 悟空 bundled-skills、企业预装镜像生态通道覆盖不了(2026-08-05 注:悟空分发线已于当日下线,「悟空 bundled-skills」一项不再适用;企业预装镜像约束仍在) | 自维护打包保留一条 |
|
||||
| 生态工具策略漂移 | 清单/发现约定/默认值被动跟随 | 作为增量通道而非唯一通道,保留 embed 兜底 |
|
||||
|
||||
## 6. 结论
|
||||
|
||||
- **生态分发通道已否决**(2026-08-04):无版本固定(`skills add` 不支持
|
||||
tag/ref,实测 v1.5.21)、依赖 Node + GitHub 可达、mono 会被一起发现、
|
||||
悟空/离线场景覆盖不了。分发维持全自维护。(2026-08-05 注:悟空分发线
|
||||
已于当日下线,「悟空场景覆盖不了」一条随之失效;其余否决理由与结论
|
||||
不变。)
|
||||
- 一个月内:按方案 P0–P2 修自维护通道并切 multi 默认(zip + embed +
|
||||
安装脚本,产物布局不变)。
|
||||
- 终态即"自维护通道修好之后"的形态,不再向 lark-cli 的生态外包形态收敛。
|
||||
- 调研保留备查:`npx -y skills add . --list` 实测可发现全部 21 个 skill
|
||||
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
|
||||
@@ -0,0 +1,307 @@
|
||||
# Skill 多 skill(multi)切换方案
|
||||
|
||||
> 目标:一个月内把 DWS 的 Agent skill 默认安装形态从 mono(单 skill)切换为
|
||||
> multi(按产品拆分),两套并行期后下掉 mono。
|
||||
> 本文基于对分发/消费链路的代码级梳理(2026-08-04),所有锚点均可跳转验证。
|
||||
> 机制调研与 lark-cli 对标细节:[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 终态 | 新装/升级默认铺 multi(`<agent-home>/dingtalk-*/`、`dws-shared/`);mono 仅 opt-in;最终物理删除 mono |
|
||||
| 并行期 | 约一个月,mono 保留可切换、可回退 |
|
||||
| 硬约束 | DWS 无服务端灰度/远程配置;skill 是本地文件分发;安装面至少 7 个且已有漂移 |
|
||||
| 前置原则 | 先修已存在的 upgrade×multi 双份 bug,再谈切默认 |
|
||||
|
||||
## 2. 现状关键事实(梳理结论)
|
||||
|
||||
分发侧:
|
||||
|
||||
- `dws-skills.zip` 布局:zip 根 = mono 副本(向后兼容)+ `mono/` + `multi/`
|
||||
(`scripts/release/post-goreleaser.sh:220-248`)。**产物无需改动**。
|
||||
- 二进制 `//go:embed all:skills/mono all:skills/multi`(`skills_embed.go:28`),
|
||||
`dws skill setup` 默认源就是 embed,**与分发渠道无关**。
|
||||
- 7 个安装/分发面:install.sh、install.ps1、install-skills.sh、npm install.js、
|
||||
Homebrew formula、install-event.sh、install-devapp.sh。
|
||||
- OSS 只发不读;大陆链路靠 Gitee fallback。
|
||||
|
||||
消费侧:
|
||||
|
||||
- `dws skill setup`:源优先级 `--source`/env → embed;目标 16 个 agent home
|
||||
(父目录门控);互斥清理 best-effort、失败仅 warning、无备份无回滚
|
||||
(`internal/app/skill_setup.go`)。
|
||||
- `dws upgrade`:`LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations`
|
||||
只写 `<agent>/dws/`、不清理 `dingtalk-*`、不更新 `~/.dws/skills`
|
||||
(`internal/upgrade/paths.go:119-172`)。**multi 用户升级后 mono+multi 共存**。
|
||||
- `dws upgrade --rollback` 只回滚二进制,不回滚 skill。
|
||||
- **无任何已安装模式状态**:`~/.dws/` 无 install manifest。
|
||||
- `~/.dws/skills` 缓存事实上只写不读(默认 setup 走 embed),且 upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
- `skill setup --target all` 不含 opencode,但 `agentSkillPaths` 含
|
||||
(`skill_command.go:119-141` vs `skill_setup.go:20-37`)。
|
||||
- 市场 skill(`dws skill install`)解压到 agent skills 根,与内置 skill 无 mode
|
||||
概念;互斥清理按 `dingtalk-*` 前缀扫描,依赖"市场无同名前缀"这一隐性约定。
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
### 3.1 单一事实源收敛(P0c)
|
||||
|
||||
agent home 清单目前在 5+ 处各写一份(注释约定 keep in sync,无门禁)。
|
||||
|
||||
- Go 侧:`skillSetupAgentHomes` 与 `knownSkillDirs` 合并为一个导出列表
|
||||
(放在 `internal/upgrade` 或新 `internal/skillhome` 包),补 opencode,
|
||||
setup/upgrade/测试共用。
|
||||
- 脚本侧:install.sh / install.ps1 / install.js / install-skills.sh /
|
||||
install-event.sh / install-devapp.sh 的清单由同一个 JSON 生成或政策脚本
|
||||
比对(新增 `scripts/policy/check-agent-homes-sync.sh`,进 `make policy`)。
|
||||
|
||||
### 3.2 安装状态文件(P0b)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划新增 `~/.dws/skills/state.json`(mode / cli_version / installed /
|
||||
previous / backup)。**已取消**:owner 决策不做运行时模式切换产品;upgrade
|
||||
有 multi 包时直接刷 multi,不再需要状态文件作为正确性或切换前提。
|
||||
|
||||
### 3.3 备份式安装与真回滚(P0a)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划把 `RemoveAll` 改为 `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/` 并
|
||||
支持失败整体回滚 / `dws skill mode rollback`。**已取消**:无运行时切换则
|
||||
不交付备份回滚产品面。安装/清理仍可为直接删除;mono retirement 版本若做
|
||||
一次性迁移,届时再单独评估临时备份,不预建 switch UX。
|
||||
|
||||
### 3.4 upgrade 包驱动 multi 刷新(P0a,已落地)
|
||||
|
||||
> 取代「按 state.json mode 刷新」与「磁盘粘性」:有 multi 包时一次性刷成 multi。
|
||||
|
||||
- `UpgradeSkillLocations(extractedDir)`:**不**推断磁盘布局。
|
||||
- 包内有 multi 技能树 → 始终 multi 刷新(清 `dws/`、刷产品 skill、刷
|
||||
`~/.dws/skills/multi`);存量 mono 在日常 upgrade 上一并迁走。
|
||||
- 无 multi 树的 legacy 包 → mono 刷新回退。
|
||||
- 这是包驱动的一次性迁移,**不是**运行时 mode-switch 产品。
|
||||
- 互斥清理在 multi 路径内执行,避免双布局长期共存。
|
||||
|
||||
### 3.5 模式切换命令(P1)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划 `dws skill mode status|set|rollback|--dry-run`。**已取消**。
|
||||
|
||||
用户若需改布局:重新走安装入口(`dws skill setup --mode <mono|multi> --yes`
|
||||
或安装脚本 `DWS_SKILL_MODE=`),不是 lifecycle 切换命令。
|
||||
|
||||
### 3.6 默认切换(P2)
|
||||
|
||||
改默认值="multi 默认、mono opt-in",七个面一起改 + 门禁锁一致性:
|
||||
|
||||
| 面 | 改动 |
|
||||
|---|---|
|
||||
| `dws skill setup` | 无 `--mode` 时默认 multi(交互选项顺序反转,mono 标 legacy) |
|
||||
| install.sh / install.ps1 | multi 分支**真正安装**(现在是打印提示跳过);`DWS_SKILL_MODE=mono` opt-in;TTY 默认项改 multi |
|
||||
| install-skills.sh / npm install.js | 从零加 mode 支持(env `DWS_SKILL_MODE` / `--skill-mode`),默认 multi |
|
||||
| Homebrew formula | caveats 改提示 `dws skill setup`(默认即 multi),无需改资源 |
|
||||
| install-event.sh / install-devapp.sh | 维持单 skill 语义,但改走共享 install 函数 |
|
||||
|
||||
文档同步:README/README_zh(`README_zh.md:81` "默认 1")、
|
||||
`skills/multi/dingtalk-skill/SKILL.md` 的 🧪 EXPERIMENTAL 措辞下调、
|
||||
install.sh 内 multi 警告文案、AGENTS.md"生产优先 mono"表述。
|
||||
|
||||
### 3.7 灰度与止血(无服务端能力下的替代)
|
||||
|
||||
- **L1 渠道灰度(先行)**:beta 轨(GitHub prerelease / npm `beta` dist-tag /
|
||||
`dws upgrade --beta`)先切默认 multi,stable 保持 mono。零新增代码。
|
||||
- **L2 确定性分桶(可选增强)**:随 release 发 `rollout.json`
|
||||
(GitHub asset + OSS 同步),安装/升级时 `hash(machine-id) % 100 < pct` 决策
|
||||
并粘入 `state.rollout`。规则:本地显式 env/flag 永远优先;拉取失败 fail-safe
|
||||
mono(并行期)/ 保持现状(切默认后);存量机器不被 rollout 改模式。
|
||||
- **Kill switch**:`rollout.json` pct=0(若上 L2,已砍)+ beta 撤回 +
|
||||
公告引导重装 `dws skill setup --mode mono --yes`(**无** `skill mode`
|
||||
命令;备份回滚产品已随 D5 取消)。
|
||||
- **可观测**:原 `x-dws-skill-mode` 请求头方案 **CANCELLED**(2026-08-05
|
||||
owner);灰度与下线判断改 issue 反馈 + 主动回访。
|
||||
|
||||
### 3.8 安装与升级的语义矩阵(两条路径都要支持 multi)
|
||||
|
||||
| 场景 | 时期 | 行为 |
|
||||
|---|---|---|
|
||||
| 新装 | 并行期(切默认前) | mono 默认,`DWS_SKILL_MODE=multi` / 交互可选 multi |
|
||||
| 新装 | 切默认后 | **multi 默认**,`DWS_SKILL_MODE=mono` / `--mode mono` / TTY opt-in(**仅安装时**) |
|
||||
| 升级(存量 mono) | 含 `multi/` 的包 | **一次性刷成 multi**(清 `dws/`);无 switch 提示命令 |
|
||||
| 升级(存量 multi) | 含 `multi/` 的包 | multi 刷新,清过期 skill,刷 multi 缓存 |
|
||||
| 升级(无安装) | 含 `multi/` 的包 | 安装 multi(与安装默认一致) |
|
||||
| 升级(任意磁盘) | legacy 无 multi 树的包 | mono 刷新回退 |
|
||||
| `dws upgrade --rollback` | 任意 | 只回滚二进制(现状);skill 无独立 rollback 命令 |
|
||||
|
||||
原则:**升级不做磁盘粘性**;有 multi 包时一律刷 multi(含存量 mono 一次性
|
||||
迁移)。默认翻转与安装 opt-in 仍只影响新装/重装。无运行时 `dws skill mode`
|
||||
产品。
|
||||
|
||||
## 4. 阶段与时间线(4 周)
|
||||
|
||||
| 周 | 内容 | 出口标准 |
|
||||
|---|---|---|
|
||||
| W1 | P0a upgrade mode-aware + P0b state.json + P0c 清单收敛与门禁 + 备份式安装 | upgrade×multi 集成测试(装 multi → upgrade → 无 `dws/` 残留);备份回滚测试(模拟中途失败);`make policy` 含 homes 同步检查 |
|
||||
| W2 | P1 `dws skill mode`(status/set/rollback/dry-run);beta 轨默认切 multi(L1);`x-dws-skill-mode` 头 | 双向切换 + 中断恢复手工验收;beta 轨冒烟 |
|
||||
| W3 | P2 stable 默认切 multi、mono 降为 opt-in;文案翻转;(可选 L2 分桶 5%→20%) | 七面默认行为一致(政策脚本);issue/请求头占比观察 |
|
||||
| W4 | (L2 则 50%→100%);mono 打 deprecation 警告,**不删代码** | 连续 7 天无 multi 相关 P1 |
|
||||
|
||||
## 5. mono 下线判据(不满足则不删)
|
||||
|
||||
> 判据更新(2026-08-05):原判据 3「悟空 bundled skill 分发线(dws_res →
|
||||
> bundled-skills,本仓库外)已切 multi」作废——悟空分发线已于当日决策
|
||||
> 下线(见 [skill-multi-roadmap.md](skill-multi-roadmap.md)
|
||||
> 「悟空线下线的影响(2026-08-05)」),无仓外 mono 依赖,mono 下线不再
|
||||
> 有仓外节奏闸门。判据重新编号如下(原 4/5 顺延为 3/4)。
|
||||
|
||||
1. `x-dws-skill-mode=multi` 请求占比 ≥ 90%;
|
||||
2. mono 主动 opt-in 率 ≤ 2%(install 脚本/命令埋点);
|
||||
3. 连续两周无 multi P1;
|
||||
4. 已有等价政策门替代 `scripts/policy/check-skill-context-budget.sh` 对
|
||||
`skills/mono/SKILL.md` 的依赖;`skills_embed.go` 去掉 `all:skills/mono`;
|
||||
存量 `<agent>/dws` 目录有"遇到即迁移清理"逻辑。
|
||||
|
||||
满足后单独一个版本窗口物理删除 `skills/mono/`,install/setup/upgrade 中 mono
|
||||
分支改为报错并指向 `dws skill mode set multi`。
|
||||
|
||||
## 6. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 半装状态(清理成功、拷贝失败) | 3.3 备份式安装,失败整体回滚 |
|
||||
| multi 用户被 upgrade 塞回 mono | 3.4 mode-aware,P0a 先修 |
|
||||
| Agent 目录清单继续漂移 | 3.1 单一事实源 + policy 门禁 |
|
||||
| 互斥清理误伤市场 `dingtalk-*` skill | 清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集,写成测试 |
|
||||
| 无灰度全切翻车 | beta 轨先行 + 备份回滚 + kill switch 公告命令 |
|
||||
| 用户不重启 AI 工具读到旧 skill | mode set / setup / install 输出统一提示重启 |
|
||||
| `~/.dws/skills` 缓存与 embed 版本漂移 | upgrade 时同步刷新对应 mode 缓存;长期可评估废弃缓存 |
|
||||
|
||||
## 7. 分发机制对标 lark-cli(2026-08-04 调研)
|
||||
|
||||
### 7.1 lark-cli 的实际分发结构
|
||||
|
||||
npm 包 `@larksuite/cli` 是一个**薄壳**(package.json `files` 只有
|
||||
`install.js` / `install-wizard.js` / `run.js` / `checksums.txt`):
|
||||
|
||||
- **二进制**:postinstall 时按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发布);镜像链 = GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底,host allowlist + checksum 双保险。`run.js` 在二进制缺失时
|
||||
自动补下载;Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:**不进 npm 包、不进二进制**。repo 根 `skills/` 目录即事实源,
|
||||
由生态通用安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)
|
||||
安装;wizard 首选 `https://open.feishu.cn` 直链、GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install`
|
||||
子命令 → install-wizard.js 串联 4 步(npm 全局装/升级 → skills 安装 →
|
||||
config init → auth login),每步幂等可跳过(`skills ls -g` 检测 `lark-*`
|
||||
已装则跳过);非 TTY 自动降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 的能力(lark-cli 免费获得的):
|
||||
|
||||
- **76 个 agent 的目录清单由生态维护**,自动探测已装 agent;project/global
|
||||
两种 scope;
|
||||
- **symlink 到 canonical 副本(推荐)或 copy** —— symlink 模式下升级
|
||||
= 更新 canonical 一份,所有 agent 即时生效;
|
||||
- `list / find / update / remove` 全套生命周期命令,skill 升级由
|
||||
`npx skills update` 承担,**lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
### 7.2 与 DWS 的关键差异
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/` 目录(源码即事实源) | zip + 二进制 embed + 5 处拷贝缓存 |
|
||||
| 安装器数量 | **1 个**(生态工具,76 agent 清单别人维护) | **7 个**自维护脚本,清单已漂移 |
|
||||
| 落盘方式 | symlink(canonical 单点更新) | 全量 copy,升级要重写 16 个 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,且不识 multi |
|
||||
| mono/multi 问题 | **不存在** —— 天生按目录多 skill | 互斥清理/模式切换/状态全是自建 |
|
||||
| npm 包体积 | 薄壳(运行时下载单平台二进制) | 全平台 archive + skills.zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS(只发不读) |
|
||||
|
||||
### 7.3 结论:生态分发通道**已否决**(2026-08-04)
|
||||
|
||||
~~借生态安装器做分发通道~~ 方向经评估后**放弃**。否决原因(均为实测):
|
||||
|
||||
1. **无版本固定**:`skills add`(v1.5.21)不支持 tag/ref 安装,而 DWS skill
|
||||
从 Cobra 树生成、与 CLI 版本强耦合,错配不可接受;唯一可复现机制
|
||||
`skills-lock.json` 仍是 experimental。
|
||||
2. **依赖 Node + GitHub 可达**:curl|sh / Homebrew / 大陆 Gitee fallback 的
|
||||
用户环境大量无 Node,生态通道在这些场景是断的。
|
||||
3. **mono 会被一起发现**:发布即制造双份,须等 mono 下线才能发布,节奏不合。
|
||||
4. **悟空 bundled / 离线预装**生态通道永远覆盖不了。(2026-08-05 注:
|
||||
悟空分发线已下线,此条约束随之消失;前 3 条否决理由仍成立。)
|
||||
|
||||
**决策:分发维持全自维护通道**(zip + embed + 安装脚本),按 P0–P2 落地;
|
||||
不从 lark-cli 借鉴分发架构。可保留的借鉴点只剩两个本地语义,与生态无关:
|
||||
|
||||
1. **wizard 式幂等安装**:`install.sh` 的 multi 分支从"打印提示跳过"改为
|
||||
检测 state 可重入的真正安装(参考 install-wizard 的步骤化幂等)。
|
||||
2. **npm 下载校验**:install.js 如后续薄壳化,可参考其 host allowlist +
|
||||
checksum 双保险与镜像链(GitHub → registry 派生 → npmmirror)设计。
|
||||
与本次 multi 迁移解耦,不单列任务。
|
||||
|
||||
## 8. 工作量评估与任务拆解
|
||||
|
||||
> 以 1 名熟悉本仓库的工程师计(人日)。规模基线:涉及生产代码约 5.0k 行
|
||||
> (Go 3.0k + shell/ps1/js 2.0k),已有测试 1.2k 行可复用。
|
||||
|
||||
### 8.1 总体判断
|
||||
|
||||
**核心路径 13–17 人日,一人一个月可行但偏紧**。风险不在 Go 而在"shell /
|
||||
ps1 / js 三语言 × 七面同步"和对应的测试矩阵。可选项(rollout 分桶、npm
|
||||
薄壳化、symlink、生态通道发布)全部可砍可后置,砍后**最小可行集 8–10
|
||||
人日**(见 8.4)。
|
||||
|
||||
### 8.2 任务拆解(带依赖)
|
||||
|
||||
```
|
||||
P0c 清单收敛 ──┐
|
||||
P0b state.json ─┼─► P0a upgrade mode-aware ─► P1 skill mode ─► P2 切默认
|
||||
备份式安装 ────┘ │ │
|
||||
└─► x-dws-skill-mode 头 └─► beta 轨先切(P2 预演)
|
||||
```
|
||||
|
||||
| # | 任务 | 改动面 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P0c-1 | agent home 清单下沉共享包(setup/upgrade 共用),补 opencode | `internal/upgrade/paths.go` 或新 `internal/skillhome`(~80 行新代码) | 0.5d | — |
|
||||
| P0c-2 | `scripts/policy/check-agent-homes-sync.sh`:比对 sh/ps1/js/sh 专项清单与 Go 清单,进 `make policy` | 新脚本 ~120 行 + 各脚本清单改成可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | state.json schema + setup 写入(含 installed 列表、agent_homes、previous) | `internal/app/skill_setup.go` +新文件 ~200 行 | 1d | — |
|
||||
| P0b-2 | 磁盘形态反推(dws/ → mono;dingtalk-* → multi;都有 → drift 报错)+ 单测 | ~120 行 | 0.5–1d | P0b-1 schema |
|
||||
| P0-3 | 备份式安装:RemoveAll→mv backup、失败自动回滚、保留最近 2 份、改 warning 语义 | setup install 两函数重写 ~150 行 + 测试 | 1.5–2d | — |
|
||||
| P0a-1 | `UpgradeSkillLocations(dir, mode)`:mono 现状+清残留;multi 从 zip `multi/` 平铺刷新+清 `dws/`+刷缓存 | `internal/upgrade/paths.go` ~150 行 | 1.5d | P0c-1、P0b |
|
||||
| P0a-2 | upgrade×multi 集成测试(装 multi→upgrade→无 dws/ 残留、dingtalk-* 已刷新) | 测试 ~200 行 | 1d | P0a-1 |
|
||||
| P0-4 | `x-dws-skill-mode` 请求头(仿 `x-dws-channel`) | `internal/auth/oauth_helpers.go` 附近 ~30 行 | 0.5d | P0b |
|
||||
| P1-1 | `dws skill mode` status/set/rollback/--dry-run | 新文件 ~350 行 + 测试 | 2–2.5d | P0b、P0-3 |
|
||||
| P1-2 | beta 轨默认切 multi(版本门控的默认值翻转,stable 不变) | setup/install.sh 默认值逻辑 ~40 行 | 0.5–1d | P1-1 |
|
||||
| P2-1 | setup 默认翻转 + 交互选项反转 + mono 标 legacy | ~30 行 + 测试更新 | 0.5d | P1 |
|
||||
| P2-2 | install.sh / install.ps1 的 multi 分支**真装**(幂等、读 state 跳过) | 两个脚本各 ~80 行 | 1.5d | P0b(脚本侧写 state) |
|
||||
| P2-3 | install.js / install-skills.sh 加 mode 支持(env + flag,默认 multi) | 各 ~60 行 | 1d | P2-2 同批 |
|
||||
| P2-4 | 文案翻转:README×2、SKILL.md EXPERIMENTAL 下调、install 脚本提示、AGENTS.md | 纯文档 | 0.5d | — |
|
||||
| W4 | mono deprecation 警告(不删代码)+ 观察 | ~20 行 | 0.5d | P2 |
|
||||
|
||||
**小计:核心 13–17 人日**(P0 ≈ 6.5–8.5,P1 ≈ 3–3.5,P2 ≈ 3.5,W4 0.5)。
|
||||
|
||||
可选/后置:rollout.json 分桶 2–3d;`setup --link` symlink 1–2d(需逐 agent
|
||||
验证);npm 薄壳化 2–3d(独立立项)。
|
||||
|
||||
### 8.3 风险最高的两处(先动)
|
||||
|
||||
1. **P0a upgrade multi 刷新语义**:additive 安装 vs upgrade 全量刷新之间存在
|
||||
一个真实设计题 —— 用户手动 `-x` 排除过的 skill,upgrade 要不要装回来?
|
||||
答案:以 state.json 的 `installed` 为准做增量刷新,未装的不得补装
|
||||
(否则违背 additive 语义)。这条必须在 P0a 开工前定死。
|
||||
2. **P0-3 备份回滚改语义**:现有测试断言"清理失败继续装",改语义会动
|
||||
`skill_setup_full_coverage_test.go` 多处;回滚恢复顺序(先恢复再报错)
|
||||
要用故障注入测试覆盖。
|
||||
|
||||
### 8.4 一个月做不完时的砍法
|
||||
|
||||
按价值/成本比从后往前砍:rollout 分桶(L1 beta 轨已够)→ install.js /
|
||||
install-skills.sh mode 支持(npm 渠道用户量小,可先只改 sh/ps1)。砍后**最小可行集 8–10 人日**:
|
||||
|
||||
> P0c-1 + P0b + P0-3 + P0a + P1-1 + P2-1 + P2-2(仅 sh/ps1)+ 文案
|
||||
|
||||
即:upgrade 不再制造双份、有状态可回滚、setup 与主流安装脚本默认 multi。
|
||||
npm/install-skills.sh 维持 mono 显式行为并在输出中标注即将切换。
|
||||
|
||||
## 9. 明确不做
|
||||
|
||||
- 不做服务端远程配置/灰度平台(用 beta 轨 + rollout.json 替代)。
|
||||
- 不动 `dws-skills.zip` 产物布局(已含 mono/multi 双树)。
|
||||
- 不动市场 skill(`dws skill install`)的安装语义。
|
||||
- 并行期内不删 mono 代码与产物,只降级为 opt-in。
|
||||
@@ -0,0 +1,153 @@
|
||||
# Skill multi 迁移:技术方案(as-implemented)与 Roadmap
|
||||
|
||||
> 本文是 multi 迁移的当前事实源:第一部分记录**已落地实现**(代码级锚点,
|
||||
> 均可跳转验证),第二部分是带实时状态的 roadmap。
|
||||
> 原始方案 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) 的
|
||||
> 若干"待做"描述已被后续决策取代(见决策记录 D1/D5);分发机制调研结论见
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
> 代码快照:`feat/skill-mode-migration` @ `402429ac` + 工作区
|
||||
> upgrade-force-multi 调整(2026-08-05)。
|
||||
|
||||
## 状态速览
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 当前阶段 | **阶段 1 / 1.5 ✅**(安装/升级默认 multi 已落地);**阶段 2 运行时切换产品线 ❌ CANCELLED**(2026-08-05 owner 决策) |
|
||||
| 硬 deadline | **2026-08-30**:安装/升级默认 multi(✅)+ upgrade **有 multi 包时一次性刷成 multi**(不做磁盘粘性)+ mono 仅安装时 opt-in;mono **物理下线**仍可在独立 retirement 版本推进,但**不再**依赖 `dws skill mode` / 备份回滚产品 |
|
||||
| 已完成 | 五面默认 multi、`dws skill setup` 默认 multi、互斥清理对称、文案翻转;upgrade 有 `multi/` 时一律刷新 multi(存量 mono 一次性迁移) |
|
||||
| 下一步 | beta/L1 版本门控与观察(靠 issue/回访,**无** `x-dws-skill-mode` 埋点);可选 agent-home 清单门禁;内容 C 线并行 |
|
||||
| 终态 | mono 仅安装时 opt-in;日常 upgrade(含 multi 的包)刷 multi;mono 物理删除仍可在 dedicated retirement 版本收尾(非用户切换命令) |
|
||||
|
||||
### 简化设计(2026-08-05 起生效)
|
||||
|
||||
1. **安装时一次决定**:默认 multi;`DWS_SKILL_MODE=mono` / `--mode mono` / 安装器 TTY 选 mono 为唯一 opt-in。
|
||||
2. **装完无运行时切换产品**:不做 `dws skill mode set/rollback`,不做备份式安装 / `state.json` 记账产品面。
|
||||
3. **升级不做粘性**:release zip 含 `multi/` 时 **一律** 刷新 multi(清 `dws/`、刷产品 skill + 缓存);仅 legacy 无 multi 树的包回退 mono 路径。存量 mono 在日常 upgrade 上一次性迁到 multi。
|
||||
4. **mono 下线**:安装侧仍可 opt-in;upgrade 已承担「有 multi 包即迁走」;物理删 mono 树可另议 retirement。
|
||||
5. **可观测**:`x-dws-skill-mode` 请求头已按 owner 决策移除;灰度靠 issue + 回访。
|
||||
|
||||
---
|
||||
|
||||
# 第一部分:技术方案(as-implemented)
|
||||
|
||||
## 1. 升级:包驱动 multi 刷新(`dws upgrade`)
|
||||
|
||||
核心语义:**升级不做磁盘粘性**;产物有 `multi/` 时一次性刷成 multi。
|
||||
无需 `state.json`,也无运行时切换命令。
|
||||
|
||||
- `LocateSkillsRoot` 优先返回 zip 内 `multi/`(`internal/upgrade/paths.go`)。
|
||||
- `UpgradeSkillLocations`:包内有 multi 技能树 → **始终** `upgradeMultiSkillLocations`
|
||||
(平铺 `dingtalk-*` + `dws-shared`,删 mono 残留 `dws/`,清过期 multi skill,
|
||||
刷 `~/.dws/skills/multi`);无 multi 树的 legacy 包才走 mono 刷新。
|
||||
- 这是 upgrade 上的一次性迁移,不是 `dws skill mode` 产品。
|
||||
|
||||
测试:`internal/upgrade/paths_multi_test.go`(含
|
||||
`TestUpgradeSkillLocationsMonoDiskMigratesToMulti`)+
|
||||
`internal/app/upgrade_skill_multi_e2e_test.go`。
|
||||
|
||||
## 2. 安装默认 multi(四个脚本面)
|
||||
|
||||
四个脚本安装面默认值全部为 multi,`DWS_SKILL_MODE=mono` 为统一 opt-in,
|
||||
互斥清理双向对称。详见阶段 1 落地说明(`scripts/install.sh` /
|
||||
`install.ps1` / `build/npm/install.js` / `scripts/install-skills.sh`)。
|
||||
|
||||
## 3. `dws skill setup` 默认 multi
|
||||
|
||||
- 非交互未指定 `--mode` 时默认 multi。
|
||||
- 交互选项 multi 在前(默认)、mono 标 legacy。
|
||||
- 仍可用 `dws skill setup --mode mono --yes` **重装**到 mono(这是安装入口,
|
||||
不是 lifecycle 切换产品;无备份/state/rollback 命令)。
|
||||
|
||||
## 4. 文案翻转
|
||||
|
||||
- `README.md` / `README_zh.md`:multi 默认、mono legacy。
|
||||
- `skills/multi/dingtalk-skill/SKILL.md`:去掉 EXPERIMENTAL。
|
||||
|
||||
## 5. 已验证
|
||||
|
||||
- `go test ./internal/upgrade ./internal/app ./test/scripts`(阶段 1 基线)+
|
||||
upgrade-force-multi 单测 / fake-HOME E2E。
|
||||
- 脚本面契约测试暴露 `DWS_SKILL_MODE` 与 mono/multi 选项。
|
||||
|
||||
## 6. 决策记录
|
||||
|
||||
- **D1 升级不依赖 state.json**(仍成立)。不读状态文件;布局由包内容驱动。
|
||||
- **D2 无服务端灰度**。L1 beta 轨 + issue/回访;L2 `rollout.json` 已砍;
|
||||
kill switch = beta 撤回 / 重装 `--mode mono`(无 `skill mode` 命令)。
|
||||
- **D3 生态分发通道已否决**(`npx skills add` 等)。见
|
||||
[skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
- **D4 互斥前缀约定**。`dingtalk-*` / `dws-shared` 属 DWS 产品 skill。
|
||||
- **D5 无运行时模式切换(2026-08-05)**。取消阶段 2 的备份式安装、
|
||||
`state.json`、`dws skill mode`(status/set/rollback)、`x-dws-skill-mode`
|
||||
请求头。Mode 只在安装时决定。
|
||||
- **D6 升级不做粘性(2026-08-05)**。有 multi 包时 upgrade 一次性刷成 multi
|
||||
(含存量 mono);legacy 无 multi 树才回退 mono 路径。
|
||||
|
||||
---
|
||||
|
||||
# 第二部分:Roadmap
|
||||
|
||||
## ✅ 阶段 1 / 1.5(已完成,2026-08-05)
|
||||
|
||||
安装/升级默认 multi、五面互斥清理、文案翻转、1.5 review 修复与实机 9/9。
|
||||
HEAD:`402429ac`。
|
||||
|
||||
## ❌ 阶段 2(原切换/状态/备份产品线)— CANCELLED(2026-08-05)
|
||||
|
||||
| 原任务 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 备份式安装(`~/.dws/skills/backup/...`) | ❌ CANCELLED | 不做运行时切换,无需备份回滚产品 |
|
||||
| `~/.dws/skills/state.json` | ❌ CANCELLED | 无切换产品;upgrade 按包刷 multi,不需要状态文件 |
|
||||
| `dws skill mode` status/set/rollback/--dry-run | ❌ CANCELLED | 无运行时切换 UX |
|
||||
| `x-dws-skill-mode` 请求头 | ❌ CANCELLED | owner 决策移除;观测改 issue/回访 |
|
||||
| agent home 清单门禁 | ⬜ 可选 | 与切换产品无关,仍可作工程质量项 |
|
||||
| `upgrade --dry-run` multi 文案 | ⬜ 可选 | 可随 force-multi 语义轻量对齐 |
|
||||
|
||||
## 重构后的 8/30 目标
|
||||
|
||||
**底线**:新装默认 multi;含 `multi/` 的 release 上 `dws upgrade` **一次性刷成
|
||||
multi**(含存量 mono);仅需保持 mono 的用户用安装入口 opt-in 后勿升级到
|
||||
含 multi 的包,或 retirement 前用 setup 重装;mono 物理删除可另议。
|
||||
|
||||
### 建议关键路径(简化)
|
||||
|
||||
```text
|
||||
阶段1默认multi ✅ → upgrade force-multi ✅ → beta/L1(可选)→ 观察(issue/回访)
|
||||
→ stable 默认已是 multi → 可选 mono retirement(删 mono 树 / 安装入口报错)
|
||||
```
|
||||
|
||||
### mono retirement(原阶段 4,重框)
|
||||
|
||||
- **不再**提供 `dws skill mode rollback` 作为用户出口。
|
||||
- 日常 upgrade 已在有 multi 包时迁走存量 mono;retirement 版本可进一步移除
|
||||
install/setup 的 mono 分支与 zip 内 mono 树。
|
||||
- 下线判据改为:issue/回访无系统性 multi P1、mono opt-in 可接受、政策门
|
||||
不再依赖 `skills/mono/SKILL.md`。(原请求头占比判据作废。)
|
||||
|
||||
## 悟空线下线的影响(2026-08-05)
|
||||
|
||||
悟空 bundled-skill 分发线已下线:mono 下线无仓外节奏闸门;`skills/multi/`
|
||||
为唯一 multi 事实源。设计资产留档
|
||||
[skill-wukong-comparison.md](skill-wukong-comparison.md)。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- ❌ 运行时模式切换(`dws skill mode set/rollback`)。
|
||||
- ❌ `state.json` / 备份式安装产品面(随 D5 取消)。
|
||||
- ❌ `x-dws-skill-mode` 请求头。
|
||||
- ❌ 服务端远程配置 / L2 `rollout.json`。
|
||||
- ❌ 运行时「模式切换」产品(含 sticky 伪切换);upgrade 有 multi 时刷 multi
|
||||
是包驱动的一次性迁移,不是 switch UX。
|
||||
- 不动 `dws-skills.zip` 双树布局(仍可含 mono 副本供安装 opt-in);不动市场
|
||||
skill(`dws skill install`)。
|
||||
|
||||
---
|
||||
|
||||
## 风险表
|
||||
|
||||
| 风险 | 现状 | 缓解 |
|
||||
|---|---|---|
|
||||
| 半装无备份 | 安装/清理仍 `RemoveAll` | 接受为非切换产品下的已知限制;重装可收敛 |
|
||||
| mono/multi 漂移 | 无 state;upgrade 有 multi 即刷 multi | 升级后收敛为 multi;安装互斥清理 |
|
||||
| 用户想换模式 | 无 switch 命令 | 文档引导:`dws skill setup --mode <mono\|multi> --yes` 重装 |
|
||||
| 8/30 滑期 | 切换产品线已砍,关键路径缩短 | 聚焦 force-multi upgrade + 默认 multi 稳定 |
|
||||
@@ -0,0 +1,290 @@
|
||||
# DWS Skill mono→multi 灰度能力设计(rollout capability)
|
||||
|
||||
> 本文回答一个问题:**在没有服务端远程配置/灰度平台的前提下,mono→multi
|
||||
> 默认翻转如何灰度发布、如何观测、如何止血**。关联文档:
|
||||
> [skill-multi-roadmap.md](skill-multi-roadmap.md)(迁移事实源,本文展开其
|
||||
> 阶段 3「灰度切流」)、[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> §3.7(灰度与止血的原始设计)。代码锚点快照:`feat/skill-mode-migration`
|
||||
> 工作区未 commit 变更(2026-08-05)。
|
||||
|
||||
---
|
||||
|
||||
## TL;DR 推荐路线
|
||||
|
||||
| 步 | 动作 | 层级 | 前置 |
|
||||
|---|---|---|---|
|
||||
| 1 | 把「五面默认 multi」拆成**版本门控默认**(beta→multi / stable 观察),同一份代码发 beta 轨先吃 | L1 | 无(本文 §2.1) |
|
||||
| 2 | ~~阶段 2 四件套(备份 / state.json / `dws skill mode` / 请求头)~~ | — | **❌ CANCELLED(2026-08-05)**:无运行时模式切换;upgrade 有 multi 时刷 multi(不做粘性) |
|
||||
| 3 | beta 轨观察:issue 流入 + 主动回访(**无**请求头占比) | 人工 | 步 1 |
|
||||
| 4 | stable:默认 multi 已在阶段 1 落地;L2 `rollout.json` 已砍 | — | — |
|
||||
| 5 | kill switch:beta 撤回(已有)/ 公告重装 `dws skill setup --mode mono --yes` | — | 无备份回滚产品 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点:今天可用于灰度的全部旋钮
|
||||
|
||||
### 1.1 旋钮总表
|
||||
|
||||
| # | 旋钮 | 代码锚点 | 生效范围 | 盲区(谁够不着) |
|
||||
|---|---|---|---|---|
|
||||
| K1 | GitHub Release 双轨(stable / prerelease) | `internal/upgrade/github.go:100-106`(`ReleaseTrack`);`internal/app/upgrade.go:870-875`(`upgradeTrack`);release.yml 强制版本→轨映射(`release.yml:547` 含 `-beta.` → prerelease) | `dws upgrade --beta` / `--version vX.Y.Z-beta.N` 的二进制+skill 升级 | 不用 `dws upgrade` 的人;Gitee/OSS 镜像用户(见 K3/K4) |
|
||||
| K2 | npm dist-tag 双轨(`latest` / `beta`) | `release.yml:1761-1762`(prerelease→`beta` tag);发布防倒退 `release.yml:1828-1835`;撤回脚本 `scripts/release/withdraw-release.sh:652-665`(dist-tag 回拨+deprecate) | `npm i dingtalk-workspace-cli@beta` 的新装/重装 | npm 默认安装(`@latest`)用户无感;npm 装完即走、不再 `npm i` 的存量 |
|
||||
| K3 | Gitee 镜像(代码 + release 资产) | main 代码镜像 `.github/workflows/mirror-to-gitee.yml:42-82`;release 资产镜像 `release.yml:1957-1965`(`sync-to-gitee.sh`);安装脚本侧 `DWS_GITEE_REPO` 解析 `scripts/install.sh:18-19`、`scripts/install-skills.sh:21-24` | curl\|sh / install-skills.sh 的国内用户(显式 env 或 GitHub 不可达自动回退) | **`dws upgrade` 不到 Gitee**:upgrade client 只打 GitHub API(`internal/app/upgrade.go:48` → `internal/upgrade/github.go`,包内无任何 Gitee 引用);Gitee release 是否 prerelease 由镜像脚本原样搬运,无独立轨控 |
|
||||
| K4 | OSS 镜像(ossutil 同步) | `scripts/release/sync-to-oss.sh:9-14`(`download/<version>/` + `latest.txt`/`beta.txt` 指针);`release.yml:1897-1908` | 今天:**只写不读**——脚本注释明示「repository installers currently resolve GitHub/Gitee and do not consume these OSS pointers directly」(`sync-to-oss.sh:5-7`) | 所有人(指针无人消费);但 `latest.txt`/`beta.txt` 是天然的**可变 channel 指针**(见 §2.2 设计复用) |
|
||||
| K5 | 安装脚本 env / flag | `DWS_SKILL_MODE`:`scripts/install.sh:17`(解析 `install.sh:220-256`)、`scripts/install.ps1:293-332`、`scripts/install-skills.sh:29-33`;npm `--skill-mode` / env `build/npm/install.js:197-214`;`DWS_VERSION` 指定 beta 版 `install.sh:38` | 新装时的逐台显式控制(CI、内推灰度名单) | 只对**执行安装那一刻**生效;装完无持久化(无 state.json),事后无法得知当初怎么装的;`DWS_VERSION=latest` 在 GitHub 侧只解析 stable(`/releases/latest` 永不指向 prerelease,`install.sh:190-198`),beta 必须显式给版本号 |
|
||||
| K6 | 版本门控默认值(构建期注入) | goreleaser ldflags 注入版本 `.goreleaser.yaml:22`(`internal/app.version=v{{.Version}}`);`prerelease: auto` `.goreleaser.yaml:68` | 同一 commit,beta build 与 stable build 可表现不同默认值(§2.1 切法 B 的机制) | 脚本面拿不到 Go 变量,需各自从「解析出的版本号」重推导(§2.1);homebrew formula 只搬 zip 根(mono 布局)到 pkgshare(`build/homebrew.rb.tmpl:26-31`),formula 本身无 mode 概念 |
|
||||
| K7 | 本地遥测(opt-in) | `internal/shortcut/usage/recorder.go:82-88`(`DWS_USAGE_TRACKING=1`,默认关);只写本地 `~/.dws/usage.jsonl`(`recorder.go:91`) | 高频命令形状挖掘(shortcut P2) | **不上传任何服务端**:对灰度占比测量零贡献;默认关意味着即使上传也无统计意义 |
|
||||
| K8 | 请求头通道(已有上行链路) | MCP 请求统一注入点 `internal/app/runner.go:953-1008`(`resolveIdentityHeaders`,接线于 `runner.go:140/240/597`、`internal/app/recovery_command.go:271/337`);登录权限检查 `internal/auth/oauth_helpers.go:1424-1428`;`x-dws-channel`(`DWS_CHANNEL`)先例 `runner.go:1006-1008`、`oauth_helpers.go:1425-1426` | 服务端(MCP 网关)已能按 header 聚合:`x-dws-agent-id`、`x-dingtalk-dws-agent-code`、`X-Cli-Version` 均在线 | 只有「已登录且发 MCP 请求」的用户可被观测;纯安装未使用、auth 失败前的用户在分子里缺席(占比偏高估,§2.3) |
|
||||
|
||||
### 1.2 结构性盲区(任何旋钮都够不着)
|
||||
|
||||
| 盲区 | 说明 | 出处 |
|
||||
|---|---|---|
|
||||
| ~~悟空 bundled skill 分发线~~(盲区已移除) | 悟空分发线(dws_res → Wukong.app bundled-skills)已于 2026-08-05 决策下线,本盲区随之移除;历史上该线在**本仓库外**、仅有 main CI 成功后的下游触发(`.github/workflows/notify-wukong.yml:13-38`),本仓库默认值翻转管不到它 | 原 roadmap 风险表「悟空线外挂」行(已解除)、阶段 4 原判据 3(已作废) |
|
||||
| homebrew 用户 | formula 只把 zip 根(mono 副本)stage 进 `pkgshare/skills/dws`(`build/homebrew.rb.tmpl:26-31`),caveats 指向 `dws skill setup`(`:33-38`);multi 源不在包内,`setup --mode multi` 只能靠 `~/.dws/skills/multi` 缓存 | 同上,K6 |
|
||||
| 永不升级的存量 mono 用户 | 所有旋钮都作用于「新装/升级/重装」三个时点;不动作的用户一切照旧(这正是灰度的天然保护层) | — |
|
||||
| 安装后不再运行 `dws` 的用户 | K8 观测不到,占比分母缺失 | §2.3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案设计(分层)
|
||||
|
||||
### 2.1 L1 渠道灰度:beta 轨先吃 multi 默认
|
||||
|
||||
目标:**同一代码、不同 release 轨不同默认值**,stable 用户在观察期内完全无感。
|
||||
|
||||
#### 切法 A:纯流程(零新增代码,不推荐单独使用)
|
||||
|
||||
当前工作区五面已无条件翻转 multi;直接发 beta 即完成「beta 先吃」。
|
||||
问题:main 上的默认值已是 multi,**下一个 stable 无处可躲**——stable 发布
|
||||
窗口一到就必须全切,观察期长短不由人;且中途想给 stable 出补丁版(hotfix)
|
||||
会被迫带上 multi 默认。仅适合「beta 观察期确定短、stable 窗口确定远」的情形。
|
||||
|
||||
#### 切法 B:版本门控默认值(推荐)
|
||||
|
||||
把五面的默认值从「无条件 multi」改为「beta 版本默认 multi,stable 版本默认
|
||||
mono」。判据统一用版本号是否含 `-beta.`(release.yml 已强制版本→轨唯一映射,
|
||||
`release.yml:547`、`.goreleaser.yaml:68`):
|
||||
|
||||
| 面 | 门控取值来源 | 落点 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` / `dws upgrade`(Go) | ldflags 注入的 `internal/app.version`(`.goreleaser.yaml:22`),`strings.Contains(version, "-beta.")` | `internal/app/skill_setup.go:354-357`(非交互默认)与 `:364-368`(交互默认项排序) |
|
||||
| install.sh / install.ps1 / install-skills.sh | 脚本自己解析出的 `$VERSION`(`install.sh:177-198`;Gitee 侧 `install.sh:180-188`)——`case "$VERSION" in *-beta.*)` | `install.sh:220-256`、`install.ps1:293-332`、`install-skills.sh:29` |
|
||||
| npm install.js | 包内 `package.json` 的 `version`(staging 时由 `stage-npm-package.sh` 写入 release 版本) | `build/npm/install.js:197-214` |
|
||||
|
||||
**关键发现——upgrade 路径(2026-08-05 更新)。** skill 升级语义是「跟着 zip
|
||||
产物布局走、不做磁盘粘性」:`LocateSkillsRoot` 恒优先 `multi/`,
|
||||
`UpgradeSkillLocations` 在包内有 multi 时**始终**刷 multi(含存量 mono
|
||||
一次性迁移,清 `dws/`)。若仍要做「stable 观察期不迁 mono」,门控必须落在
|
||||
**是否发布含 multi 的 zip / 是否走 upgrade skill 刷新**,而不是磁盘粘性分支
|
||||
(粘性方案已否决)。当前产品默认接受:含 multi 的 release 上 upgrade = 迁
|
||||
multi。
|
||||
|
||||
切法 B 的安装默认门控(beta→multi / stable→mono)仍可独立存在;与 upgrade
|
||||
force-multi 正交——安装 opt-in mono 的用户一旦升级含 multi 的包会被迁走。
|
||||
|
||||
#### 风险与回退
|
||||
|
||||
| 风险 | 说明 | 回退 |
|
||||
|---|---|---|
|
||||
| 门控不可见 | 默认值随版本号变化,review/测试容易漏 | 每面补契约测试(beta→multi / stable→mono),`test/scripts` 已有同构先例(`test/scripts/install_script_test.go:529-576`) |
|
||||
| 升级迁走 mono | **接受为产品语义**:有 multi 包时 upgrade 一次性刷 multi;需 mono 则 `dws skill setup --mode mono --yes` 重装(装完后再 upgrade 仍可能被迁回) | S1 撤回含 multi 的坏包;S3 公告重装 |
|
||||
| beta 轨整体有毒 | 二进制或 skill 包级事故 | 现有撤回链:`scripts/release/withdraw-release.sh`(GitHub release 撤回 + npm deprecate + dist-tag 回滚,`withdraw-release.sh:652-665`);stable 轨不受影响 |
|
||||
| 版本字符串被仿造 | 本地 `go build` 无版本注入时 `version=""`,门控落 stable 分支(保守方向,正确) | — |
|
||||
|
||||
### 2.2 L2 确定性分桶:随 release 发 `rollout.json`
|
||||
|
||||
L1 的粒度是「轨」:beta 全吃、stable 全不吃。stable 切流若要 5%→100% 的
|
||||
渐进,需要机器级分桶。无服务端,用**随版本分发的只读配置 + 客户端确定性
|
||||
哈希**替代。
|
||||
|
||||
#### rollout.json schema 与发布链路
|
||||
|
||||
作为 release 资产随每个版本发出(进 `dist/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"skill_mode": {
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"note": "mono->multi default rollout for stable track"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pct`:0–100,`bucket < pct` 的机器默认 multi。
|
||||
- `salt`:分桶盐,换盐=重新洗牌(默认不换,保证跨版本粘性可比)。
|
||||
- 发布链路改动:`scripts/release/post-goreleaser.sh` 生成进 `dist/`;
|
||||
**资产命名空间是精确集合**(`scripts/release/verify-release-artifacts.sh:12-38`,
|
||||
「public release assets must contain exactly the supported files」),必须把
|
||||
`rollout.json` 加进 EXPECTED_ASSETS;stable 晋升门会比较 beta 资产集
|
||||
(`release.yml:833-846`),所以引入该资产的那个 beta 起两轨必须同时带。
|
||||
- checksums.txt 由 dist 自动生成,镜像脚本(Gitee `release.yml:1957-1965`、
|
||||
OSS `sync-to-oss.sh:9-14`)整目录搬运,**rollout.json 自动随资产集流到
|
||||
Gitee/OSS,无需额外接线**。
|
||||
|
||||
#### machine-id 来源:读现成,不新建
|
||||
|
||||
`internal/auth/identity.go` 已有稳定 per-install UUID v4 `machineId`
|
||||
(`identity.go:19-20`、结构体 `:70-76`),持久化在 `~/.dws/identity.json`
|
||||
(`identity.go:51` + `pkg/config/constants.go:177-188`),惰性生成
|
||||
(`EnsureExists` `:115-133`),v1 文件透明迁移(`:98-113`)。分桶直接复用:
|
||||
|
||||
```
|
||||
bucket = int(sha256(machineId + "|" + salt)[:8], 16) % 100
|
||||
```
|
||||
|
||||
边界情况:`identity.json` 首建于首次 MCP 请求链路(`runner.go:954`)。灰度
|
||||
决策发生在 setup/upgrade 时,可能早于任何 MCP 请求——此时按 `EnsureExists`
|
||||
同款语义**就地惰性创建**(best-effort 持久化,失败则用进程内随机值且当次
|
||||
不记账,下次重决)。不引入第二套 `~/.dws/install-id`,避免双事实源。
|
||||
|
||||
脚本面(sh/ps1)做 sha256 分桶要读 JSON + 哈希,复杂且易错;npm install.js
|
||||
用 node crypto 是一行。**范围划定:L2 分桶只在 Go 面(`dws upgrade` /
|
||||
`dws skill setup` / 未来 `dws skill mode`)与 npm install.js 实现**;sh/ps1
|
||||
停在 L1 版本门控(curl 用户全是新装,渠道轨已够;百分比分桶的主战场是存量
|
||||
升级,而升级必过 Go 二进制)。
|
||||
|
||||
#### state.json 的 rollout 字段
|
||||
|
||||
依赖阶段 2 的 `~/.dws/skills/state.json`(P0b,未实现,roadmap 阶段 2):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "multi",
|
||||
"rollout": {
|
||||
"bucket": 37,
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"decision": "multi",
|
||||
"decided_at": "2026-08-20T08:00:00Z",
|
||||
"decided_by": "rollout.json@v1.4.2",
|
||||
"explicit": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
决策顺序(每台机器只决策一次,粘性):
|
||||
|
||||
1. 本地显式(`DWS_SKILL_MODE` / `--mode` / `--skill-mode` / `dws skill mode set`)
|
||||
→ 用之,`explicit=true`;
|
||||
2. `state.json` 已有 `mode` 或磁盘形态可反推(roadmap 阶段 2 既定兜底)
|
||||
→ 保持现状,不参与分桶;
|
||||
3. `state.rollout.decision` 已存在 → 复用(pct 后续变化不翻案);
|
||||
4. 拉取 `rollout.json`(Go 面:upgrade 已下载本版资产,同 release 再取一个
|
||||
小文件;npm:包内自带)→ 算 bucket 决策并记账;
|
||||
5. 任一步失败 → 当期默认(L1 版本门控结果)。
|
||||
|
||||
#### 三条硬规则
|
||||
|
||||
| 规则 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| R1 本地显式优先 | env/flag/命令任何时候压过 rollout 决策;显式选择落 `explicit=true` 后 rollout 永不改它 | 灰度不能覆盖用户意志;也是 kill switch 的用户侧出口 |
|
||||
| R2 拉取失败 fail-safe | 拉不到/解析失败/字段越界 → 保持现状(存量)或当期默认(新装),**绝不因拉取失败翻模式** | 无服务端下网络面即故障面,故障必须倒向保守侧 |
|
||||
| R3 存量不被改模式 | 已有 mode(state 或磁盘可推)的机器不参与分桶;pct 只影响「未决策」机器 | pct 从 20 降到 0 不能把已进 multi 的 20% 弹回 mono(那需要 kill switch,不是分桶语义) |
|
||||
|
||||
#### 与 Gitee / immutable release 的兼容
|
||||
|
||||
- **Gitee**:`dws upgrade` 不读 Gitee(§1.1-K3),rollout.json 经
|
||||
reconcile/sync 脚本随资产集镜像到 Gitee release;Gitee 侧安装脚本如需消费,
|
||||
走与 `dws-skills.zip` 相同的 Gitee API 资产枚举(`install.sh:180-188` 同
|
||||
模式)。一期不消费、只保证镜像不缺失。
|
||||
- **immutable release 约束**:官方仓已开启不可变 release(`release.yml:802`
|
||||
「Immutable releases must be enabled before publishing」),**资产发布后不可
|
||||
替换**——调 pct = 发一个新补丁版(beta 线 release.yml 支持连续 beta:
|
||||
`release_bump` 在连续 beta 线时被忽略,`release.yml:25-27`)。这决定了
|
||||
L2 的调参时延 = 一次发版;追求更快止血见 §2.4。
|
||||
- (可选远期)OSS `latest.txt`/`beta.txt` 是现成的**可变**指针
|
||||
(`sync-to-oss.sh:13-14`),若未来 installer 学会读 OSS,可把
|
||||
`rollout-current.json` 放 OSS 变指针后面实现「不发版调 pct」;今天无消费
|
||||
方,不建。
|
||||
|
||||
### 2.3 L3 可观测性:`x-dws-skill-mode` 请求头 — ❌ CANCELLED
|
||||
|
||||
**2026-08-05 owner 决策:不实现该请求头**(与运行时模式切换 / state.json
|
||||
一并取消)。原设计(注入 `resolveIdentityHeaders`、按 state/磁盘上报
|
||||
`mono|multi|unknown`)仅作历史记录,不进入排期。
|
||||
|
||||
观察手段改为:**issue 反馈 + 主动回访**;不再有请求级 multi 占比判据。
|
||||
|
||||
### 2.4 Kill switch:四层止血
|
||||
|
||||
| 层 | 手段 | 时延 | 现状/依赖 |
|
||||
|---|---|---|---|
|
||||
| S1 beta 轨整体撤回 | `scripts/release/withdraw-release.sh`:GitHub release 撤回 + npm deprecate + `beta` dist-tag 回拨(`:652-665`) | 分钟级 | **今天可用** |
|
||||
| S2 rollout.json `pct=0` | 发补丁版把 pct 打 0(immutable release 不允许原地改资产,§2.2) | 一次发版(小时级) | 依赖 L2 落地 |
|
||||
| S3 公告命令 | 公告用户重装 `dws skill setup --mode mono --yes`(安装入口,非 switch 产品) | 用户触达时延 | **可用**(无 `dws skill mode`;阶段 2 切换命令已 CANCELLED) |
|
||||
| S4 备份回滚 | ~~`dws skill mode rollback` + 备份式安装~~ | — | **❌ CANCELLED(2026-08-05)** 与运行时切换一并取消 |
|
||||
|
||||
二进制侧另有既有的 `dws upgrade --rollback`(`internal/upgrade/rollback.go`,
|
||||
备份在 `~/.dws/data/backups`、保留 5 份 `:16/:54-63`),但只回滚二进制不回滚
|
||||
skill 布局——skill 止血靠 S1 + S3(重装 mono),**无** S4。
|
||||
|
||||
**结论(2026-08-05):** kill switch = S1(beta 撤回)+ S3(公告重装
|
||||
`--mode mono`)。S4 已取消;日常 upgrade 有 multi 包时**一次性刷 multi**
|
||||
(不做粘性),故「装完 mono 再 upgrade」会迁走——止血靠撤回坏包或重装。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对标(简要)
|
||||
|
||||
**npm dist-tag 双轨(lark-cli 类企业内部 CLI 的通行形态)。** 以 npm 仓
|
||||
registry 为唯一分发面时,灰度即 dist-tag:`latest` 稳态、`beta`/`next` 先行
|
||||
(`next@canary`、`typescript@beta` 同款模式),安装侧 `npm i pkg@beta` 或
|
||||
CI 指定 tag 即完成分群;撤回即 `npm dist-tag add pkg@<prev> latest` +
|
||||
`npm deprecate`。DWS 已完整具备此形态(§1.1-K2),lark-cli 等内部 CLI 在
|
||||
集团内网 registry 上亦按同一范式运作——差别只在内部 registry 可附带按
|
||||
员工/部门灰度的下发规则,那是「registry 有服务端」的红利,DWS 面向公网
|
||||
npm 没有这一层,故需 L2 补齐。
|
||||
|
||||
**安装时下载器内版本选择(deno / rustup 模式)。** `curl | sh` 安装器不显式
|
||||
给版本时,先拉一个**可变 channel 指针文件**(如 deno 的
|
||||
`dl.deno.land/release-latest.txt`、rustup 的 channel manifest),再按指针下载
|
||||
真实产物——指针一改全量新装即转向,**不发版即可调流**。DWS 的 OSS
|
||||
`latest.txt`/`beta.txt`(`sync-to-oss.sh:13-14`)已是同构物,只差安装脚本消费
|
||||
它;install.sh 今天直接打 GitHub `/releases/latest` 重定向(`install.sh:190-198`),
|
||||
等价于把 GitHub 当不可调指针用。此模式是指针级灰度,做不到机器级百分比,
|
||||
需与 L2 分桶叠加。
|
||||
|
||||
**双产物并行(VS Code Stable / Insiders 模式)。** 两个渠道各发各的包、用户
|
||||
自选安装,灰度靠「渠道人口结构」自然形成,无需任何运行时门控。DWS 的
|
||||
GitHub prerelease + npm `@beta` 已是它的轻量版(同包不同 tag 而非两个包名),
|
||||
L1 切法 B 的版本门控默认正是把「渠道差异」从纯流程下沉为可测试的代码事实。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐路线与改动点清单
|
||||
|
||||
### 4.1 路线(与 roadmap 阶段 2/3 对齐后的排序)
|
||||
|
||||
```
|
||||
L1 版本门控拆分(本工作区之上叠加,先合入)
|
||||
→ ~~阶段 2 四件套~~ ❌ CANCELLED(无运行时切换;upgrade force-multi)
|
||||
→ beta 轨发版先吃(L1 自动生效)+ L3 观察 ≥2 周
|
||||
→ stable 切流:小步直接 100%(删门控);若要求渐进再上 L2 分桶
|
||||
→ mono retirement 判据(roadmap:issue/回访,**无**请求头占比)
|
||||
```
|
||||
|
||||
### 4.2 改动点清单(文件级)
|
||||
|
||||
| 项 | 文件 | 改动 | 估时 |
|
||||
|---|---|---|---|
|
||||
| L1-a Go 门控函数 | `internal/app/skill_setup.go`(或新 `internal/upgrade/track.go` 下沉共享) | `defaultSkillModeForVersion(version)`:含 `-beta.`→multi 否则 mono;替换非交互默认与交互排序 | 0.5d |
|
||||
| L1-b upgrade force-multi(已落地) | `internal/upgrade/paths.go` `UpgradeSkillLocations` | 有 multi→始终 multi(含 mono 盘迁移);legacy 无 multi→mono | ✅ |
|
||||
| L1-c 脚本面门控 | `scripts/install.sh` / `install.ps1` / `install-skills.sh` / `build/npm/install.js` | 默认值解析加 `*-beta.*` 分支(若仍做 L1) | 0.5d |
|
||||
| L1-d 契约测试 | `test/scripts` / `internal/upgrade` / `internal/app` | 每面断言 beta→multi;upgrade mono→multi E2E | 0.5–1d |
|
||||
| L3 请求头 | — | **❌ CANCELLED** | — |
|
||||
| L2-a/b/c rollout.json | — | **已砍**(roadmap) | — |
|
||||
| S3/S4 | — | S3=重装 mono(可用);S4 备份/rollback **❌ CANCELLED** | — |
|
||||
|
||||
合计:L1 ≈ 2–2.5d;L3 ≈ 0.5d;L2 ≈ 2.5–3d(在 state.json 之后)。
|
||||
L1+L3 是进入 beta 观察期的最小集;L2 只在 stable 需要渐进切流时才启动,
|
||||
否则删门控一步到位即可。
|
||||
|
||||
### 4.3 明确不做(沿用 roadmap/D2,本文补充)
|
||||
|
||||
- 不建服务端远程配置/灰度平台;不为灰度单独引入可变配置下发通道(OSS 变
|
||||
指针仅作远期可选,今天无消费方)。
|
||||
- 不动 `dws-skills.zip` 产物布局(D1);不按轨发不同 zip——轨差异全部落在
|
||||
版本门控的客户端行为上。
|
||||
- 不用遥测做灰度测量(K7 本地 opt-in 无统计意义),观测只走 K8 请求头。
|
||||
@@ -0,0 +1,340 @@
|
||||
# DWS multi-skill vs 悟空(dws-wukong)分发线对比
|
||||
|
||||
> ⚠️ **留档注记(2026-08-05)**:悟空(dws-wukong)bundled-skill 分发线
|
||||
> 已于 2026-08-05 决策下线。本文自此仅作**历史调研留档**,不再作为任何
|
||||
> 对齐依据——文中的「判据 #3」「对齐清单(§7.1)」「待确认问题(§7.2)」
|
||||
> 等均随悟空线下线而 MOOT。其中 bundle 的自描述打包设计
|
||||
> (`manifest.json` + `scripts/_install.sh`)与 symlink 提升消费方式
|
||||
> 可作为未来打包方案参考保留。
|
||||
>
|
||||
> 撰写日期:2026-08-05。聚焦 **multi-skill** 主题:DWS 侧(本工作区
|
||||
> `feat/skill-mode-migration`)已把 multi 翻转为全通道默认,而 mono 下线
|
||||
> 原判据 #3 曾要求"悟空 bundled skill 分发线(dws_res → bundled-skills)
|
||||
> 已切 multi"([skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4、
|
||||
> [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §5;
|
||||
> 该判据已于 2026-08-05 随悟空线下线作废)。
|
||||
> 本文盘清悟空线现状、两边差异、切换影响与对齐清单。
|
||||
>
|
||||
> 配套阅读:[skill-multi-roadmap.md](skill-multi-roadmap.md)(DWS 侧
|
||||
> as-implemented 事实源)、[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
> (DWS 分发/消费链路调研)。
|
||||
|
||||
## 0. 摘要(TL;DR)
|
||||
|
||||
- **DWS 侧**:multi(`skills/multi/`,19 个 `dingtalk-*` 产品 skill +
|
||||
`dws-shared`)已是安装(4 脚本面)、升级、`dws skill setup` 五面默认;
|
||||
产物 `dws-skills.zip` 恒含"根 mono 副本 + `mono/` + `multi/`"三树,
|
||||
二进制 embed 双树(`skills_embed.go:28`)。**产物零改动即完成默认翻转**。
|
||||
- **悟空侧**:multi 打包能力**已合入 dws-wukong `develop`**(merge
|
||||
`9eb801e5`,"DWS MultiSkill 与 Qwen Work Cloud 六平台打包"),但
|
||||
**正式发版 target `make real-platform` 仍只打 mono**
|
||||
`dingtalk-workspace.zip`;multi 以 `dingtalk-workspace-bundle.zip` 双包
|
||||
形态存在(`bundle-platform` / `package-dual`),本地有 2026-07-03 的双包
|
||||
实测产物,但**未随已发布版本出门**(`release/0.2.97`–`0.2.99` 均不含该
|
||||
merge)。
|
||||
- **关键缺口在客户端**:RewindDesktop(悟空桌面端)构建期
|
||||
`download_binary.py` 只认 `dingtalk-workspace.zip` 单 zip;运行时
|
||||
`dws_update.rs` 灰度更新也只 upsert 单个 `dingtalk-workspace` skill;
|
||||
全仓 grep 无 `dingtalk-workspace-bundle` / T4b 处理。**端内尚无消费
|
||||
multi bundle 的代码路径**。
|
||||
- **悟空线的 multi 与 DWS 的 multi 是两套独立维护的树**(dws-wukong
|
||||
`dingtalk-skills/` 12 产品 + `dws-shared`,由本仓 mono 机械派生;DWS
|
||||
`skills/multi/` 19 产品 + `dws-shared`),内容靠 SOP 人工对齐,无自动
|
||||
同源。判据 #3 的"切 multi"首先要解决的是**打包形态 + 端内加载**,
|
||||
内容同源是紧随其后的问题。
|
||||
- 结论(历史):判据 #3 远未满足。对齐需要 dws-wukong 仓(发版 target)、
|
||||
RewindDesktop 仓(构建期 + 运行时两条加载路径)两侧改动,详见 §7 清单。
|
||||
**(2026-08-05 MOOT:悟空线下线,判据 #3 已作废——见
|
||||
[skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4 判据更新;
|
||||
上述对齐工作不再需要。)**
|
||||
|
||||
## 1. 证据源与版本快照
|
||||
|
||||
本地仓库(均为真实磁盘证据,非仅凭文档):
|
||||
|
||||
| 仓库 | 本地路径 | 核查时状态 |
|
||||
|---|---|---|
|
||||
| DWS(本仓) | `~/GolandProjects/open-source/dws-skill-mode-migration` | `feat/skill-mode-migration`,含未 commit 的阶段 1 变更 |
|
||||
| dws-wukong 主仓 | `~/GolandProjects/open-source/dws-wukong` | checkout `codex/deploy-qwenwork-dev`(落后 develop 602 commits);本文 Makefile/脚本结论均以 `develop` 分支内容(`git show develop:...`)为准 |
|
||||
| dws-wukong multiSkill 工作区 | `~/GolandProjects/open-source/dws-wukong-multiSkill` | detached @ `9eb801e5`(multiSkill merge 本体),`target/` 有 2026-07 实测产物 |
|
||||
| RewindDesktop | `~/IdeaProjects/RewindDesktop` | checkout `dws/0.2.98`;`develop` 上 `DEFAULT_DWS_RES_URL` = pod `0.2.96` |
|
||||
|
||||
关键版本事实:
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| multiSkill merge `9eb801e5` 已入 `develop` 与本地 `release/0.2.100` | `git branch --contains 9eb801e5` |
|
||||
| `release/0.2.97` / `0.2.98` / `0.2.99` **不含** multiSkill merge;其 `real-platform` 不打 bundle | `git merge-base --is-ancestor` + `git show origin/release/0.2.99:Makefile` |
|
||||
| 当前发给悟空的 pod 包为 mono:`dws_res_mac.zip` 内仅 `dingtalk-workspace.zip`(单 skill,`SKILL.md`+`references/products/*`)+ 双架构二进制 | 主仓 `target/dws_res_mac.zip`(2026-06-01)`unzip -l` 实测 |
|
||||
| 双包形态已实测:`dws_res_mac/` 同时含 `dingtalk-workspace.zip`(924K)与 `dingtalk-workspace-bundle.zip`(1.27M) | multiSkill 工作区 `target/dws_res_mac.zip`(2026-07-03)实测 |
|
||||
| multi bundle 内部布局:`manifest.json` + `scripts/_install.sh` + `skills/<name>/SKILL.md...` 平铺目录 | multiSkill 工作区 `target/dingtalk-workspace.zip`(2026-07-24)实测 |
|
||||
|
||||
**本地未能验证**(详见 §7.2 问题清单):pod 线上当前包内容(需内网
|
||||
SSO);`/Applications/Wukong.app` 未安装在本机(bundled-skills 目录不存在,
|
||||
无法核对在端真实 zip);RewindDesktop 端"T4b 二选一"灰度逻辑(全仓无匹配,
|
||||
疑似未开发或在平台侧);Qwen Work Cloud 六平台打包的实际发布状态。
|
||||
|
||||
## 2. 分发链路对比
|
||||
|
||||
### 2.1 链路全景
|
||||
|
||||
DWS 侧(本仓,multi 已默认):
|
||||
|
||||
```text
|
||||
skills/mono/ ─┬─ go:embed all:skills/mono all:skills/multi (skills_embed.go:28)
|
||||
skills/multi/ ┘ │ `dws skill setup` 默认源(embed 优先)
|
||||
│
|
||||
└─ scripts/release/post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
(根 = mono 副本 + mono/ + multi/,三树恒含)
|
||||
│
|
||||
GitHub Release / Gitee / OSS / npm tarball / Homebrew / 专项脚本
|
||||
│
|
||||
install.sh · install.ps1 · install-skills.sh · npm install.js(四面默认 multi)
|
||||
+ `dws skill setup`(第五面默认 multi)+ `dws upgrade`(布局探测→multi 刷新)
|
||||
│
|
||||
各 agent home 平铺 dingtalk-*/(互斥清理 dws/ 与过期 skill)
|
||||
+ ~/.dws/skills/{mono,multi} 双缓存
|
||||
```
|
||||
|
||||
悟空侧(dws-wukong + RewindDesktop,发版线仍 mono):
|
||||
|
||||
```text
|
||||
上游 CLI 仓 ../dingtalk-workspace-cli(go.mod replace,sync-upstream 按
|
||||
CLI_UPSTREAM_TAG 重建 release 分支)──► 只提供 Go 代码,不提供 skill 内容
|
||||
|
||||
dws-wukong 仓内 skill 内容(自维护):
|
||||
dingtalk-workspace/(mono 源,含 overlays/real)
|
||||
├─ build-workspace-zip ──► dingtalk-workspace.zip(mono,单 skill)
|
||||
└─ scripts/sync-monolith-to-multiskill.py ──► dingtalk-skills/(multi 派生树)
|
||||
└─ scripts/build-bundle.sh ──► dingtalk-workspace-bundle.zip
|
||||
(manifest.json + scripts/_install.sh + skills/<name>/)
|
||||
|
||||
make real-platform(发版默认)──► dws_res_{mac,win}.zip
|
||||
= dws 二进制 + dingtalk-workspace.zip(仅 mono)
|
||||
make bundle-platform / package-dual(已合入 develop,未用于正式发版)
|
||||
= dws 二进制 + dingtalk-workspace.zip(mono 兜底)+ dingtalk-workspace-bundle.zip
|
||||
│
|
||||
pod.alibaba-inc.com zipUpload(SSO 浏览器上传,版本号独立递增)
|
||||
│
|
||||
RewindDesktop scripts/download_binary.py(DEFAULT_DWS_RES_URL 手工对齐)
|
||||
│
|
||||
Wukong.app Contents/Resources/resources/
|
||||
├─ dws/bin/dws(二进制)
|
||||
└─ bundled-skills/dingtalk-workspace.zip(原样拷贝的单 zip)
|
||||
│
|
||||
运行时两条路:
|
||||
a) 启动同步 initialize_bundled_skills_from_resources
|
||||
→ 解 zip 到中央技能库 ~/.real/.skills/bundled/dingtalk-workspace
|
||||
(及 ~/.real/users/*/.skills/bundled)
|
||||
b) 灰度自更新 dws_update.rs:Gaea 开关 wukong/dws_auto_update_enabled_v2
|
||||
→ LWP /r/Adaptor/DwsGrayI/getLatest 取 {version,url,sha256}
|
||||
→ 下载 dws_res → 换 seed 二进制 + 换 bundled zip + upsert 单 skill
|
||||
```
|
||||
|
||||
### 2.2 链路对照表
|
||||
|
||||
| 环节 | DWS(本仓) | 悟空线 | 锚点(悟空侧) |
|
||||
|---|---|---|---|
|
||||
| skill 事实源 | `skills/mono` + `skills/multi`(同仓双树,multi 19 产品 + dws-shared) | dws-wukong 仓 `dingtalk-workspace/`(mono 源)→ 派生 `dingtalk-skills/`(12 产品 + dws-shared);**与上游 skills/ 无自动同步** | `scripts/sync-monolith-to-multiskill.py` docstring |
|
||||
| 二进制与 skill 的版本耦合 | embed 进二进制,天然同版 | 二进制来自上游 tag(`go.mod:58` replace + `sync-upstream` pin `CLI_UPSTREAM_TAG`);skill 在 dws-wukong 仓随 `VERSION`/`main.go` 双写发版 | dws-wukong `Makefile` `sync-upstream` |
|
||||
| 打包产物 | `dws-skills.zip`:根 mono 副本 + `mono/` + `multi/`(`post-goreleaser.sh:220-248`);embed 双树 | `dws_res_{mac,win}.zip`:二进制 + `dingtalk-workspace.zip`(mono);双包 target 已存在但未上发版线 | dws-wukong `Makefile` `real-platform` / `bundle-platform` / `package-mac-dual` |
|
||||
| 渠道 | GitHub/Gitee/OSS/npm/Homebrew/专项脚本,7 个安装面 | pod zipUpload(SSO)→ RewindDesktop `download_binary.py` → 客户端 bundle | release skill 文档 + `download_binary.py:72-84` |
|
||||
| 端内安装 | 4 脚本 + `dws skill setup` 平铺到 16 个 agent home(父目录门控) | 构建期拷贝 zip 进 app 资源;启动时解到 `~/.real/.skills/bundled/` | `startup.rs:486-554` |
|
||||
| 运行时更新 | `dws upgrade`(布局探测→multi 刷新 + 互斥清理 + 缓存刷新) | 客户端灰度自更新(Gaea + LWP),整包替换 seed 二进制 + skill zip | `dws_update.rs:107,27-28,440-545` |
|
||||
| 灰度能力 | 无服务端:beta 轨(L1)+ 可选 `rollout.json` 分桶(L2)+ 公告 kill switch(决策 D2) | 有服务端:Gaea 开关 + LWP getLatest + pod 版本号 | 决策记录 D2 vs `dws_update.rs` |
|
||||
|
||||
## 3. skill 布局对比
|
||||
|
||||
### 3.1 三种形态
|
||||
|
||||
| 形态 | 布局 | 消费方 |
|
||||
|---|---|---|
|
||||
| DWS mono | 单 skill:`SKILL.md` + `references/` + `scripts/`,装进 `<agent-home>/dws/` | DWS legacy 安装面;悟空 `dingtalk-workspace.zip` 同构(多 `plugins/`、real overlay) |
|
||||
| DWS multi | 平铺目录树:`multi/dingtalk-<product>/{SKILL.md,references,scripts}` + `multi/dws-shared/`,无 manifest、无安装器,拷贝即平铺到 agent home | DWS 五面默认;`dws upgrade` 探测 `multi/` 树(`internal/upgrade/paths.go:363-369`) |
|
||||
| 悟空 multi bundle | zip 内 `manifest.json`(`{"version":...}`)+ `scripts/_install.sh` + `skills/<name>/` 平铺目录(含 `dws-shared`) | **Qwen Work Cloud 已消费**(`_install.sh` 把 `skills/*` 以 symlink 提升到一层 skills 根,供 Codex/OpenCode 扫描;`.dws-multiskill-current` + `.dws-multiskill-links` 记账);**Wukong.app 尚未消费** |
|
||||
|
||||
注意两种 multi 的"平铺"语义不同:DWS multi 是**裸目录树**,由安装面自己
|
||||
拷贝到各 agent home;悟空 bundle 是**自描述包**(manifest + 安装脚本),
|
||||
由消费方解包后提升。`dws-skills.zip` 的 `multi/` 树与
|
||||
`dingtalk-workspace-bundle.zip` 的 `skills/` 树**布局同构但内容不同源**
|
||||
(见 §3.3)。
|
||||
|
||||
### 3.2 悟空客户端加载约定(RewindDesktop 实测)
|
||||
|
||||
构建期(`scripts/download_binary.py`):
|
||||
|
||||
- `DWS_RES_WORKSPACE_ZIP = "dingtalk-workspace.zip"`(`:103`),
|
||||
`resolve_dws_res_contents`(`:1366-1380`)强制 dws_res 内必须同时有
|
||||
平台二进制和**这个文件名的 zip**——`dingtalk-workspace-bundle.zip` 会被
|
||||
原样忽略(不报错,但也不使用)。
|
||||
- `sync_dingtalk_workspace_bundle`(`:1422-1432`)把该 zip **原样拷贝**到
|
||||
`tauri-app/src-tauri/resources/bundled-skills/dingtalk-workspace.zip`,不解包。
|
||||
|
||||
运行时(`tauri-app/src-tauri/src/skills/startup.rs`):
|
||||
|
||||
- `collect_bundled_skill_sources`(`:486-506`)扫描 `resources/bundled-skills/`:
|
||||
**每个子目录或每个 `.zip` = 一个 skill**,`skill_id = 文件主干名`
|
||||
(`dingtalk-workspace.zip` → skill id `dingtalk-workspace`)。
|
||||
该扫描**天然支持多 skill 平铺**(放 `dingtalk-mail.zip`、`dingtalk-doc.zip`
|
||||
就会被分别注册),但**不认识嵌套 bundle**:若把
|
||||
`dingtalk-workspace-bundle.zip` 丢进去,只会被当成一个名叫
|
||||
`dingtalk-workspace-bundle` 的单 skill 解开(内容是 manifest+skills/ 目录,
|
||||
不会被提升)。
|
||||
- `sync_bundled_skill_source`(`:533-554`)解 zip 拷贝进中央技能库;
|
||||
`cleanup_removed_bundled_skills`(`:508-531`)会删除 bundled-skills 里
|
||||
已不存在的 bundled skill(mono→multi 切换时可自动清掉旧
|
||||
`dingtalk-workspace`,前提是 store 记录完好)。
|
||||
- 灰度自更新 `dws_update.rs:440-545` 硬编码单 skill:
|
||||
`upsert_bundled_skill_from_source(store, DWS_WORKSPACE_SKILL_ID, …)`
|
||||
(`DWS_WORKSPACE_SKILL_ID = "dingtalk-workspace"`),换包 = 替换
|
||||
`bundled-skills/dingtalk-workspace.zip` + 重 upsert 这一个 skill。
|
||||
|
||||
结论:端内**构建期与运行时两条路都按"单 zip 单 skill"接线**;要支持
|
||||
multi,二选一:(a) 端内学会解 bundle(manifest + 提升子 skill),或
|
||||
(b) 打包侧把每个子 skill 打成独立 zip 平铺进 dws_res,复用现有平铺扫描
|
||||
(仅需把 `dws_update.rs` 的单 skill upsert 改为遍历)。详见 §6.2。
|
||||
|
||||
### 3.3 两套 multi 树的集合差异
|
||||
|
||||
| | DWS `skills/multi/` | dws-wukong `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 产品 skill 数 | 19 | 12 |
|
||||
| 共有(12 个) | aisearch, aitable, calendar, chat, contact, doc, drive, mail, minutes, misc, todo, wiki | 同左 |
|
||||
| 仅 DWS 有(7 个) | dev, event, hrbrain, markdown, pat, profile, skill | — |
|
||||
| 共享层 | `dws-shared` | `dws-shared`(内容独立维护) |
|
||||
| 内容来源 | 本仓直接维护 | 由本仓 mono `dingtalk-workspace/` 经 `sync-monolith-to-multiskill.py` 机械派生(链接改写 + misc 桶归并) |
|
||||
| 场景 skill | 不涉及 | `dingtalk-products-skills/` 23 个 scenario skill **仅保留源码,不进统一发布包**(`build-bundle.sh` 头注释) |
|
||||
|
||||
含义:即使悟空线明天切到 multi 形态,其 multi **内容**与 DWS multi 也不
|
||||
一致(少 7 个产品、各自演化)。判据 #3 只要求"分发线切 multi"(形态),
|
||||
但长期看内容同源(或明确的子集契约)需要一并决策。
|
||||
**(2026-08-05 MOOT:悟空线下线,"DWS skills/multi(19) vs 悟空
|
||||
dingtalk-skills(12) 分歧"的长期统一问题随之作废,无需统一;DWS
|
||||
`skills/multi/` 成为唯一 multi 事实源。)**
|
||||
|
||||
## 4. 版本对齐对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| skill↔CLI 耦合 | embed 进二进制,`dws skill setup` 装的就是本二进制版本(`skills_embed.go` + `skill_setup_embed.go`) | 二进制版本 = 上游 tag(`sync-upstream` pin);skill 版本 = dws-wukong `Makefile VERSION` + `main.go version` 双写;**两者只通过"同一次发版动作"对齐,无结构性强约束** |
|
||||
| 产物版本 | `dws-skills.zip` 随 goreleaser 与二进制同 tag 发布 | pod 版本号独立于 dws 版本(右most 段 +1 递增,mac/win 各自一条线,如 mac `0.2.28.0`、win `0.2.2`);RewindDesktop `DEFAULT_DWS_RES_URL` 手工改指 |
|
||||
| 端内版本事实源 | — | 客户端以 `dws --version` 输出为准(`get_dws_version_sync`,失败回退 `versions.json`);skill zip 无独立版本概念(mono zip 内无 manifest) |
|
||||
| multi 包版本 | 无 manifest;版本 = 所属 zip/二进制版本 | bundle 内 `manifest.json` 带 `version`(`build-bundle.sh` 由 `$(VERSION)` 写入)——**multi 形态反而第一次给 skill 包带来了显式版本号** |
|
||||
| 升级时的布局兼容 | 新 zip 恒含 `multi/`,`LocateSkillsRoot` 优先 multi;老 zip 自然落回 mono(决策 D1,产物零改动) | dws_res 布局固定(二进制 + workspace zip);双包形态下 mono zip 保留作兜底(`package-mac-dual` 注释:"端内 validate 必含,bundle 缺失时兜底") |
|
||||
|
||||
## 5. 更新 / 回滚对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| 更新触发 | 用户主动 `dws upgrade` / 重装脚本 | 两条:(a) 随客户端版本更新(app 内嵌资源替换 + 启动同步);(b) 运行时灰度自更新(Gaea `dws_auto_update_enabled_v2` + LWP getLatest → 下载整包 → 换 seed 二进制 + 换 bundled zip + upsert skill) |
|
||||
| skill 刷新语义 | 布局探测(multi 优先)+ 按 home 互斥清理(删 `dws/`、过期 `dingtalk-*`/`dws-shared`),清理失败则该 home 不装,杜绝共存(`internal/upgrade/paths.go:217-299`) | 启动同步按 bundled-skills 现状全量对账(新增拷贝、缺失删除,`startup.rs:508-554`);自更新路径是单 zip 替换 + 单 skill upsert(`dws_update.rs:462-513`) |
|
||||
| 回滚 | `dws upgrade --rollback` 只回二进制不回 skill;备份式安装 + `dws skill mode rollback` 是阶段 2 P0,**尚未落地** | 无显式回滚命令;事实回滚 = Gaea 开关关闭/改指旧 pod 版本重新下发,或客户端版本回退;`replace-wukong-skill.sh`(仅存在于 `codex/deploy-qwenwork-dev` 分支)提供人工替换 + 重启 |
|
||||
| 半装保护 | 现状 `RemoveAll` 直删、失败仅 warning(风险表已列,阶段 2 改备份式) | zip 整体替换 + preflight 可写性检查,失败提示重启;粒度为整个 skill 包,无子 skill 级半装概念 |
|
||||
| 离线/内网 | embed 兜底(无网可 setup) | app 内嵌 zip 兜底;灰度通道依赖内网 LWP/pod 可达 |
|
||||
|
||||
## 6. 切换影响分析
|
||||
|
||||
### 6.1 DWS 切 multi 默认 / 最终删 mono 对悟空线的影响点
|
||||
|
||||
| # | 影响点 | 评估 |
|
||||
|---|---|---|
|
||||
| 1 | DWS 删 `skills/mono` 后上游 embed 只剩 multi;悟空二进制由上游 tag 构建,`dws skill setup` 行为随之变 | **低**。悟空客户端不从 embed 装 skill(走 bundled zip);但悟空用户在端内手动跑 `dws skill setup` 时会得到 multi——行为变化需在悟空侧公告 |
|
||||
| 2 | `dws-skills.zip` 布局(根 mono + mono/ + multi/) | **零影响**。悟空线不消费 `dws-skills.zip`;DWS 侧也已承诺不动该产物(决策 D1、"明确不做") |
|
||||
| 3 | dws-wukong 的 mono 内容源 `dingtalk-workspace/` 与上游 `skills/mono` 本就各自维护 | **低(但需注意)**。上游删 mono 不会直接打破 dws-wukong 构建;但两边 mono 的"内容漂移对照基准"消失,dws-wukong mono 将彻底成为孤儿副本,加速与上游 CLI 能力的文档漂移 |
|
||||
| 4 | mono 下线判据 #3 反向卡住 DWS 侧进度 | **高(流程性)**。悟空线一天不切 multi,DWS 就不能物理删 `skills/mono/`(roadmap 风险表"悟空线外挂"行)。**(已解除 2026-08-05:悟空线下线,判据 #3 作废,DWS 侧进度不再受仓外闸门约束)** |
|
||||
|
||||
### 6.2 悟空线"吃 multi"的改造选项
|
||||
|
||||
**选项 A:端内解 bundle(dws-wukong 现有 bundle 产物直接被消费)**
|
||||
|
||||
- dws-wukong 侧:`real-platform` 改打(或加打)`dingtalk-workspace-bundle.zip`
|
||||
——`bundle-platform` / `package-dual` 已就绪,基本零新开发。
|
||||
- RewindDesktop 侧(主要工作量):
|
||||
- `download_binary.py`:`resolve_dws_res_contents` 接受/校验
|
||||
`dingtalk-workspace-bundle.zip`,同步进 `resources/bundled-skills/`;
|
||||
- `startup.rs`:识别 bundle(`manifest.json` + `skills/*`),把每个子
|
||||
skill 注册为独立 bundled skill(逻辑等价于 `_install.sh` 的提升,
|
||||
但落在中央技能库);
|
||||
- `dws_update.rs`:灰度自更新从"单 skill upsert"改为"bundle 全量对账"
|
||||
(可复用启动同步的对账逻辑)。
|
||||
- 优点:与 Qwen Work Cloud 已消费的包形态一致,一份产物两个端;bundle 自
|
||||
带 `manifest.json` 版本号。
|
||||
- 缺点:端内要新增 bundle 解析/提升代码与测试;`skills/` 嵌套布局与现有
|
||||
"一 zip 一 skill"约定不同,需谨慎处理迁移期(旧 mono skill 清理)。
|
||||
|
||||
**选项 B:打包侧拆 zip(端内零新格式)**
|
||||
|
||||
- dws-wukong 侧:新增 target 把 `dingtalk-skills/` 每个子 skill 打成独立
|
||||
zip(`dingtalk-mail.zip` … `dws-shared.zip`)平铺进 dws_res。
|
||||
- RewindDesktop 侧:`download_binary.py` 改为同步多个 zip;
|
||||
`startup.rs` 现有平铺扫描**零改动**(自动注册每个 zip);
|
||||
`dws_update.rs` 仍需从单 skill upsert 改为遍历。
|
||||
- 优点:复用端内现有"一 zip 一 skill"约定,启动路径几乎不动。
|
||||
- 缺点:与 Qwen Work 的 bundle 形态分叉(一份内容两种包);dws_res 内文件
|
||||
数膨胀;`dws-shared` 作为独立 skill id 出现在用户可见列表里需要确认
|
||||
端内展示策略。
|
||||
|
||||
**选项 C(过渡态,事实已在用)**:双包并存——mono zip 兜底 + bundle 灰度,
|
||||
端内按灰度二选一。`package-dual` 的注释已写明此意图("端内 T4b 二选一,
|
||||
monolith 端内 validate 必含,bundle 缺失时兜底"),但**端内 T4b/灰度选择
|
||||
逻辑在 RewindDesktop 尚未找到实现**,当前双包发出去也只会用 mono。
|
||||
|
||||
### 6.3 mono 下线判据 #3 的建议验收方式
|
||||
|
||||
> **(2026-08-05 MOOT:判据 #3 已随悟空线下线作废,本节验收清单不再
|
||||
> 适用,仅留档。)**
|
||||
|
||||
判据原文:"悟空 bundled skill 分发线(dws_res → bundled-skills,本仓库
|
||||
外)已切 multi"(原 [skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
§5-3;该判据已于 2026-08-05 作废,plan §5 已重新编号)。建议按以下
|
||||
可核查项验收(全绿才算满足):
|
||||
|
||||
1. **发版**:dws-wukong 正式 release 流程(release skill 文档中的
|
||||
`make real-platform` 路径)产出的 dws_res 内含 multi 形态包(bundle 或
|
||||
平铺 zip 集),且 pod 上当前版本即为该形态。
|
||||
2. **构建期消费**:RewindDesktop `develop` 的 `download_binary.py` 把 multi
|
||||
形态同步进 `bundled-skills/`(不再是只认 `dingtalk-workspace.zip`)。
|
||||
3. **运行时消费**:悟空端启动同步后,`~/.real/.skills/bundled/` 下出现
|
||||
`dingtalk-*` 多 skill(而非单个 `dingtalk-workspace`);灰度自更新路径
|
||||
同样支持多 skill 对账。
|
||||
4. **实机回归**:全新安装悟空 → 技能列表出现各 `dingtalk-*` skill 且
|
||||
路由正常;从 mono 旧版升级 → 旧 `dingtalk-workspace` 被清理、无双份
|
||||
派发;灰度通道下发一次 multi 包 → 更新后无残留。
|
||||
5. **回退预案**:悟空侧保留 mono 兜底产物或快速重发能力,直至 DWS 删
|
||||
mono 窗口关闭。
|
||||
|
||||
## 7. 结论
|
||||
|
||||
### 7.1 对齐清单(悟空侧待办,按优先级)
|
||||
|
||||
> **(2026-08-05 MOOT:悟空线下线,本清单整体不再需要执行,仅留档。)**
|
||||
|
||||
| 优先级 | 事项 | 仓库/位置 | 备注 |
|
||||
|---|---|---|---|
|
||||
| P0 | 决策端内 multi 消费方案(选项 A 解 bundle vs 选项 B 平铺 zip) | RewindDesktop + dws-wukong 联合 | 建议 A:与 Qwen Work 已消费形态一致,且 bundle 带版本 manifest |
|
||||
| P0 | `download_binary.py` 支持 multi 形态同步进 bundled-skills | RewindDesktop `scripts/download_binary.py:103,1366-1432` | 选项 A 下识别 `dingtalk-workspace-bundle.zip` |
|
||||
| P0 | 启动同步/技能库支持 bundle 解包与子 skill 注册 | RewindDesktop `tauri-app/src-tauri/src/skills/startup.rs:486-554` | 含旧 mono skill 的迁移清理(现有 `cleanup_removed_bundled_skills` 可复用语义) |
|
||||
| P0 | 灰度自更新支持 multi(单 skill upsert → 多 skill 对账) | RewindDesktop `.../dws_update.rs:440-545` | 否则运行时更新会把 multi 打回 mono |
|
||||
| P1 | 正式发版 target 切 multi(`real-platform` 改打/加打 bundle,或改用 `bundle-platform`/`package-dual`) | dws-wukong `Makefile` | 打包能力已在 develop,缺的是设为默认 + release skill 文档同步更新 |
|
||||
| P1 | 端内灰度选择逻辑落地(双包二选一/T4b,或确认直接全量切) | RewindDesktop(未找到现有实现) | 若选选项 C 过渡则必须 |
|
||||
| P1 | 两套 multi 树的内容同源策略:dws-wukong `dingtalk-skills/`(12 产品)vs DWS `skills/multi/`(19 产品) | dws-wukong + 本仓 | **MOOT(2026-08-05)**:悟空线下线,无需统一;原备注:至少明确"悟空子集"契约与同步 SOP 的归属;7 个缺失产品(dev/event/hrbrain/markdown/pat/profile/skill)是否需要进悟空 |
|
||||
| P2 | `replace-wukong-skill.sh` 等运维脚本支持 bundle 形态 | dws-wukong `scripts/deploy/` | 当前只在特性分支且只处理单 zip |
|
||||
| P2 | 悟空侧公告:端内 `dws skill setup` 行为随上游 embed 变化 | dws-wukong 发版流程 | 对应 §6.1 影响点 1 |
|
||||
| P2 | 判据 #3 验收清单(§6.3)写入 DWS roadmap 并跟踪 | 本仓 `docs/skill-multi-roadmap.md` | 阶段 3 期间启动 |
|
||||
|
||||
### 7.2 待确认问题(本地无法闭环,需找人/仓库确认)
|
||||
|
||||
| # | 问题 | 建议确认方 |
|
||||
|---|---|---|
|
||||
| 1 | pod 线上当前 `dws_res_mac/win` 的版本与内部构成(是否已有人发过双包) | pod.alibaba-inc.com(需内网 SSO)/ 悟空发版 owner |
|
||||
| 2 | "端内 T4b 二选一"灰度逻辑是否已存在(在哪个仓库/平台),还是仅写在 Makefile 注释里的规划 | RewindDesktop 团队 / 悟空端内灰度平台 owner |
|
||||
| 3 | LWP `/r/Adaptor/DwsGrayI/getLatest` 服务端返回的下载 URL 指向何处(pod?另一制品库?),multi 包下发是否需要服务端配合改造 | Adaptor/DwsGrayI 服务端 owner |
|
||||
| 4 | Qwen Work Cloud 六平台打包的发布状态与其对 bundle 的消费方式是否可作为悟空端改造的直接参照 | dws-wukong 仓 owner(merge `9eb801e5` 提交者) |
|
||||
| 5 | 悟空端内是否允许 `dws-shared` 作为独立 bundled skill 暴露(名称/展示/路由策略),还是应内联进各产品 skill | RewindDesktop 技能库 owner |
|
||||
| 6 | dws-wukong `dingtalk-skills/` 与上游 `skills/multi/` 的长期关系:保持派生自本仓 mono,还是改为从上游 multi 同步(**MOOT 2026-08-05**:悟空线下线,无需统一) | dws-wukong + DWS 双侧 owner 联合决策 |
|
||||
| 7 | 悟空线切换的目标时间窗(决定 DWS 阶段 4 判据 #3 的最早可满足点)(**MOOT 2026-08-05**:判据 #3 已作废) | 悟空发版 owner |
|
||||
|
||||
---
|
||||
|
||||
*本文所有"已验证"结论均可按 §1 的仓库路径与文中锚点复查;未能本地验证
|
||||
的项集中在 §7.2。*
|
||||
@@ -2193,8 +2193,8 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2214,8 +2214,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2248,7 +2251,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2291,8 +2294,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2300,17 +2303,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -75,15 +75,14 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
multi 多 skill(默认)—— 按产品拆 N 个独立 skill(dingtalk-*)
|
||||
mono 单 skill(legacy)—— 总入口 SKILL.md + references/products/
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
用 -s/-x 挑选时未列出的已有 dingtalk-* skill 会保留(additive 叠加语义);
|
||||
不带过滤条件的全量安装会清理不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
@@ -168,8 +167,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
|
||||
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
|
||||
// install.js 对齐,清掉不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
filtered := len(includeRaw) > 0 || len(excludeRaw) > 0
|
||||
|
||||
if !autoYes {
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames, filtered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -177,8 +181,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -186,7 +188,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut, filtered)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -251,10 +253,10 @@ func normalizeMultiSkillName(name string) string {
|
||||
// - both lists empty → return `all` (install everything)
|
||||
// - exclude that drops every name → error (avoid silent no-op install)
|
||||
//
|
||||
// The caller is responsible for additive installation: install only the
|
||||
// returned names, leaving any other already-installed dingtalk-* siblings
|
||||
// untouched (handled by installMultiSkillToHomes which does not enumerate
|
||||
// the destination).
|
||||
// The caller threads whether a filter was used into installMultiSkillToHomes:
|
||||
// filtered installs stay additive (already-installed dingtalk-* siblings are
|
||||
// left untouched); a full unfiltered install also removes stale dingtalk-* /
|
||||
// dws-shared directories that are no longer part of the bundle.
|
||||
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||||
if len(include) > 0 && len(exclude) > 0 {
|
||||
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
|
||||
@@ -356,8 +358,8 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
if autoYes || !skillSetupInteractive() {
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||
return skillSetupModeMono, nil
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 multi")
|
||||
return skillSetupModeMulti, nil
|
||||
}
|
||||
|
||||
var choice string
|
||||
@@ -365,10 +367,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("multi = 按产品拆分(默认)\nmono = 单 skill 入口(legacy)").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(默认)", skillSetupModeMulti),
|
||||
huh.NewOption("mono — 单 skill(legacy)", skillSetupModeMono),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -380,8 +382,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
// resolveSkillSetupSource finds the local skill source directory for the
|
||||
// given mode. PR 1 supports only mono; multi is reserved for a later PR
|
||||
// and currently returns an error before reaching this function.
|
||||
// given mode ("mono" or "multi"). Explicit overrides (--source flag or
|
||||
// DWS_SKILL_SOURCE) win and never fall back to another source; without an
|
||||
// override the ordered candidate list (binary-adjacent, working directory,
|
||||
// ~/.dws/skills user cache) is probed for a valid skill root of that mode.
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
@@ -525,16 +529,7 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string, filtered bool) (bool, error) {
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
@@ -549,10 +544,21 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
|
||||
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
|
||||
for _, d := range dests {
|
||||
for _, victim := range mutualExclusionVictims(d, mode) {
|
||||
victims, _ := mutualExclusionVictims(d, mode) // 预览只读,扫描失败不阻塞确认
|
||||
for _, victim := range victims {
|
||||
fmt.Fprintf(out, " × 将删除 %s\n", victim)
|
||||
}
|
||||
}
|
||||
// 全量 multi 安装还会清掉不在 bundle 内的过期 dingtalk-* / dws-shared
|
||||
// (removeStaleMultiSkills);这些删除同样必须先进入确认预览。带
|
||||
// -s/-x 的 filtered 安装是 additive 语义,不会动未列出的 sibling。
|
||||
if mode == skillSetupModeMulti && !filtered {
|
||||
for _, d := range dests {
|
||||
for _, victim := range staleMultiSkillVictims(d, multiSkillNames) {
|
||||
fmt.Fprintf(out, " × 将删除过期 skill %s\n", victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skillSetupInteractive() {
|
||||
return true, nil
|
||||
@@ -580,38 +586,49 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
//
|
||||
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
|
||||
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
|
||||
func mutualExclusionVictims(dest, mode string) []string {
|
||||
//
|
||||
// A scan failure (e.g. unreadable agent home) is returned as a non-nil error
|
||||
// so callers can surface a warning instead of silently skipping cleanup.
|
||||
func mutualExclusionVictims(dest, mode string) ([]string, error) {
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
// dest = <agent-home>/dws → agent-home = parent
|
||||
agentHome := filepath.Dir(dest)
|
||||
entries, err := skillSetupReadDir(agentHome)
|
||||
if err != nil {
|
||||
return nil
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("扫描 multi 残留失败 %s: %w", agentHome, err)
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
||||
if e.IsDir() && (strings.HasPrefix(e.Name(), multiSkillPrefix) || e.Name() == multiSharedSkill) {
|
||||
victims = append(victims, filepath.Join(agentHome, e.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
return victims, nil
|
||||
case skillSetupModeMulti:
|
||||
// dest = <agent-home> → mono 残留是 dest/dws
|
||||
monoPath := filepath.Join(dest, "dws")
|
||||
if _, err := skillSetupStat(monoPath); err == nil {
|
||||
return []string{monoPath}
|
||||
return []string{monoPath}, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
|
||||
// Failures emit a warning to errOut but never abort the install.
|
||||
// Failures — including a failed victim scan — emit a warning to errOut but
|
||||
// never abort the install.
|
||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||
victims, scanErr := mutualExclusionVictims(dest, mode)
|
||||
if scanErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理扫描失败(继续安装) %s: %v\n", dest, scanErr)
|
||||
}
|
||||
for _, victim := range victims {
|
||||
if err := skillSetupRemoveAll(victim); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||
continue
|
||||
@@ -650,7 +667,13 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
//
|
||||
// filtered mirrors whether runSkillSetup saw -s/--skill or -x/--exclude:
|
||||
// a filtered install stays additive and never touches siblings outside the
|
||||
// requested set; a full (unfiltered) install additionally removes stale
|
||||
// dingtalk-* / dws-shared directories that are no longer in the bundle,
|
||||
// matching install.sh / install.ps1 / install.js / upgrade paths.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer, filtered bool) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||
@@ -662,6 +685,10 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
if !filtered {
|
||||
removeStaleMultiSkills(dest, skillNames, out, errOut)
|
||||
}
|
||||
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -682,6 +709,65 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
// staleMultiSkillVictims lists the dingtalk-* / dws-shared directories under
|
||||
// dest that a full (unfiltered) multi install would delete because they are
|
||||
// not part of the bundle. It is the read-only preview companion of
|
||||
// removeStaleMultiSkills; scan failures degrade to a nil list so the
|
||||
// confirmation prompt is never blocked by an unreadable agent home.
|
||||
func staleMultiSkillVictims(dest string, keep []string) []string {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
victims = append(victims, filepath.Join(dest, e.Name()))
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
}
|
||||
|
||||
// removeStaleMultiSkills deletes dingtalk-* / dws-shared directories under
|
||||
// dest that are not part of the current bundle. Best-effort: scan/removal
|
||||
// failures warn on errOut and never abort the install.
|
||||
func removeStaleMultiSkills(dest string, keep []string, out, errOut io.Writer) {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 扫描失败(继续安装) %s: %v\n", dest, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
stale := filepath.Join(dest, e.Name())
|
||||
if err := skillSetupRemoveAll(stale); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 清理失败(继续安装) %s: %v\n", stale, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理过期 skill %s\n", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
|
||||
@@ -86,12 +86,12 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -113,7 +113,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
@@ -143,7 +143,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -202,11 +202,11 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
@@ -231,18 +231,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -36,14 +38,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
if !strings.Contains(buf.String(), "multi") {
|
||||
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +212,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
@@ -254,13 +256,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if vErr != nil {
|
||||
t.Fatalf("mutualExclusionVictims err: %v", vErr)
|
||||
}
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -482,7 +487,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -549,3 +554,114 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
|
||||
// multi install removes stale dingtalk-* / dws-shared directories that are no
|
||||
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
|
||||
// The additive counterpart (filtered install) is covered by
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings.
|
||||
func TestSkillSetupMultiFullInstallCleansStale(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
|
||||
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 1 || skipped != 0 {
|
||||
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Errorf("missing installed skill: %v", err)
|
||||
}
|
||||
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
|
||||
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理过期 skill") {
|
||||
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
|
||||
// surfaces as an errOut warning instead of silently skipping cleanup.
|
||||
func TestSkillSetupMutualExclusionScanWarning(t *testing.T) {
|
||||
oldReadDir := skillSetupReadDir
|
||||
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
|
||||
scanFail := errors.New("scan boom")
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
|
||||
t.Fatal("scan failure should surface as an error")
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
|
||||
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
|
||||
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
|
||||
// full install cleans stale siblings while a filtered install stays additive.
|
||||
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
|
||||
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
|
||||
oldList, oldFilter, oldMulti := skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
|
||||
skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti = oldList, oldFilter, oldMulti
|
||||
})
|
||||
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(t.TempDir(), "dest")}, nil
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
var gotFiltered []bool
|
||||
skillSetupInstallMulti = func(_ string, _ []string, _ []string, _, _ io.Writer, filtered bool) (int, int, error) {
|
||||
gotFiltered = append(gotFiltered, filtered)
|
||||
return 1, 0, nil
|
||||
}
|
||||
|
||||
// Full install (no -s/-x): filtered must be false.
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("full install run err: %v", err)
|
||||
}
|
||||
|
||||
// Filtered install: filtered must be true.
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("filtered install run err: %v", err)
|
||||
}
|
||||
|
||||
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
|
||||
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ var (
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
locateUpgradeSkill = upgrade.LocateSkillsRoot
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
// TestUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
|
||||
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
|
||||
// layout then calling UpgradeSkillLocations with a multi bundle must install
|
||||
// product skills, remove dws/, and leave non-DWS dirs alone.
|
||||
func TestUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := filepath.Join(extract, "multi")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
dir := filepath.Join(multiRoot, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }}}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"}, "至少需要一个修改项"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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_",
|
||||
} {
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)...)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user