Compare commits

..
Author SHA1 Message Date
玉澜andCursor 96774e6e23 Remove retired discovery cache CLI and doctor cache check.
Drop the no-op dws cache stubs and the doctor cache health item, and
scrub skill/AGENTS guidance that still pointed agents at them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:28:05 +08:00
玉澜 c1d90672a8 test(schema): drop compact leaf size ceiling 2026-08-06 00:56:13 +08:00
玉澜 3d2d287723 feat(schema): bound agent views and prevent instruction drift 2026-08-06 00:49:31 +08:00
玉澜andCursor 969292a8d7 declare(ding): semantic ParamDecl backfill for schema inference residuals
Complete ding leaf Property/Required from Execute CallMCP keys and live
help semantics, and allowlist the four inference→declare property remaps
so schema-compat does not freeze wrong camelCase flag names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:19:19 +08:00
github-actions[bot] 4bcf71fb9e Merge pull request #881 from Anonymity-0/feat/chat-reply-mentions
feat(chat): support mentions in message replies
2026-08-05 23:10:42 +08:00
前津 545ee17316 fix(chat): add missing reply mention placeholders 2026-08-05 21:23:12 +08:00
前津 0c62938f74 feat(chat): support mentions in message replies 2026-08-05 21:23:12 +08:00
github-actions[bot] 45b43e52bb chore: update beta formula for v1.0.57-beta.2 [skip ci] 2026-08-05 11:49:09 +00:00
chichuan 95a5cc42ce Merge pull request #879 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.2
docs: seal v1.0.57-beta.2 changelog
2026-08-05 19:36:25 +08:00
chichuan fec750b09e docs: remove duplicate beta.2 changelog entry 2026-08-05 19:26:27 +08:00
chichuan ddd5f15b91 docs: seal v1.0.57-beta.2 changelog 2026-08-05 19:19:49 +08:00
github-actions[bot] db50be868b Merge pull request #876 from DingTalk-Real-AI/codex/restore-chat-im-compat
fix(chat): restore stable send and history compatibility
2026-08-05 19:13:50 +08:00
Dennis a6220d7d8b fix(chat): preserve migration hints with legacy flags 2026-08-05 18:19:16 +08:00
Dennis 81bf0d2a6b test(coverage): stabilize drive worker cancellation branch 2026-08-05 18:05:54 +08:00
Dennis f3a95d34a3 fix(chat): restore stable send and history compatibility 2026-08-05 17:32:12 +08:00
72 changed files with 890 additions and 3664 deletions
+15 -12
View File
@@ -302,9 +302,8 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -321,8 +320,10 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+14
View File
@@ -6,6 +6,19 @@ 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
@@ -59,6 +72,7 @@ and compatibility and CI reliability fixes.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.56-beta.4"
version "1.0.57-beta.2"
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.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-arm64.tar.gz"
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-arm64.tar.gz"
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
end
def install
+1
View File
@@ -88,6 +88,7 @@ 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-skill-commands.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
+8 -8
View File
@@ -371,7 +371,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -380,14 +380,14 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
### Agent Skills
@@ -624,7 +624,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -636,9 +636,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema --all # full export for CI/audit/baselines
```
+8 -8
View File
@@ -365,7 +365,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -374,14 +374,14 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
### Agent Skills
@@ -618,7 +618,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -630,9 +630,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
+11 -10
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+7 -6
View File
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
+5 -28
View File
@@ -358,12 +358,11 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
}
}
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
func TestCrossPlatformCoverageConfigCommandRemainingCoverage(t *testing.T) {
command := newConfigCommand()
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
t.Setenv("DWS_CONFIG_DIR", "configured")
configCmd := &cobra.Command{Use: "config"}
@@ -381,26 +380,4 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
if err := runConfigList(list, nil); err != nil {
t.Fatal(err)
}
cacheRoot := &cobra.Command{Use: "root"}
cacheRoot.PersistentFlags().String("format", "", "")
cacheCmd := &cobra.Command{Use: "cache"}
cacheRoot.AddCommand(cacheCmd)
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
-76
View File
@@ -1,76 +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 (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
)
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
Replacement string `json:"replacement,omitempty"`
}
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
sub := &cobra.Command{
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
}
return cmd
}
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
return err
}
}
+1 -23
View File
@@ -732,7 +732,7 @@ func TestCrossPlatformCoverageEventCommandValidationCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
func TestCrossPlatformCoverageVersionCompletionCoverage(t *testing.T) {
oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
t.Cleanup(func() { version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit })
version, buildTime, gitCommit = "1.0", "unknown", "unknown"
@@ -753,25 +753,6 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
t.Fatal(err)
}
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
for _, format := range []string{"json", "pretty", "table"} {
_ = root.PersistentFlags().Set("format", format)
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
}
cache := newCacheCommand()
cache.SetOut(io.Discard)
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
completionRoot := &cobra.Command{Use: "dws"}
completionRoot.AddCommand(&cobra.Command{Use: "child"})
for _, shell := range []string{"bash", "zsh", "fish"} {
@@ -1747,9 +1728,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
-1
View File
@@ -300,7 +300,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newCompletionCommand(dedupRoot)
_ = newCatalogCommand()
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
+1 -22
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
-25
View File
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
+1 -2
View File
@@ -473,7 +473,6 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
@@ -722,7 +721,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// hideNonDirectRuntimeCommands) and reservedCommands (the plugin-override
// blocklist) derive from this single set so they cannot drift apart.
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"auth": true, "api": true, "audit": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
+37 -14
View File
@@ -72,6 +72,39 @@ 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 {
@@ -386,20 +419,10 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
func TestCacheCommandRemoved(t *testing.T) {
root := NewRootCommand()
if cmd, _, err := root.Find([]string{"cache"}); err == nil && cmd != nil && cmd != root {
t.Fatalf("dws cache compatibility stub must be removed, found %q", cmd.CommandPath())
}
}
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
+64 -46
View File
@@ -1162,69 +1162,87 @@ func strconvQuote(value string) string {
// ─── --compact mode ──────────────────────────────────────────────────────────
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
var schemaCompactStripKeys = map[string]bool{
// provenance / debug
"agent_metadata_source": true,
"agent_source_refs": true,
"agent_summary_source": true,
"effect_source": true,
"metadata_source": true,
"source": true,
"agent_metadata": true,
"field_provenance": true,
"reviewed": true,
// redundant with canonical_path / cli_path
"name": true,
"path": true,
"cli_name": true,
"primary_cli_path": true,
"is_alias": true,
"has_parameters": true,
"parameter_count": true,
"product_id": true,
"display": true,
"title": true,
"group": true,
"source_product_id": true,
"aliases": true,
"catalog_hash": true,
"surface_hash": true,
"workflow_refs": true,
"prerequisites": true,
"tips": true,
"interface_ref": true,
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
// positive list: a new full/audit field must not silently expand routine Agent
// context just because it was added to ToolSpec.ToPayload.
var schemaCompactPayloadKeys = map[string]bool{
// Navigation envelopes.
"kind": true, "level": true, "count": true, "tool_count": true,
"products": true, "product": true, "tools": true,
"id": true, "schema_path": true, "runtime": true,
// Leaf identity and execution semantics.
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"examples": true, "use_when": true, "avoid_when": true,
}
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
var schemaCompactParamStripKeys = map[string]bool{
"interface_description": true,
"interface_type": true,
"property": true,
"field_provenance": true,
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
// construction. RPC mapping and provenance fields intentionally remain in the
// full/audit view.
var schemaCompactParamKeys = map[string]bool{
"type": true, "description": true, "required": true,
"cli_required": true, "required_when": true,
"default": true, "interface_default": true, "example": true,
"format": true, "enum": true,
}
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
// debug and redundant keys so that only agent-essential fields remain.
// It operates recursively on nested maps, slices, and parameter objects.
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
result := make(map[string]any, len(payload))
for k, v := range payload {
if schemaCompactStripKeys[k] {
if !schemaCompactPayloadKeys[k] {
continue
}
if k == "parameters" {
switch k {
case "parameters":
result[k] = stripSchemaParametersCompact(v)
continue
case "product":
if product, ok := v.(map[string]any); ok {
result[k] = stripSchemaPayloadCompact(product)
} else {
result[k] = v
}
case "products", "tools":
result[k] = stripSchemaPayloadCollectionCompact(v)
default:
result[k] = v
}
result[k] = stripSchemaValueCompact(v)
}
return result
}
func stripSchemaPayloadCollectionCompact(value any) any {
switch values := value.(type) {
case []map[string]any:
result := make([]map[string]any, len(values))
for i, item := range values {
result[i] = stripSchemaPayloadCompact(item)
}
return result
case []any:
result := make([]any, len(values))
for i, item := range values {
if payload, ok := item.(map[string]any); ok {
result[i] = stripSchemaPayloadCompact(payload)
} else {
result[i] = item
}
}
return result
default:
return value
}
}
func stripSchemaParametersCompact(value any) any {
parameters, ok := value.(map[string]any)
if !ok {
@@ -1278,7 +1296,7 @@ func stripSchemaValueCompact(v any) any {
func stripSchemaParamCompact(param map[string]any) map[string]any {
result := make(map[string]any, len(param))
for k, v := range param {
if schemaCompactParamStripKeys[k] {
if !schemaCompactParamKeys[k] {
continue
}
result[k] = v
+60 -2
View File
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
leaf["future_audit_field"] = "must not leak into Agent view"
for _, raw := range schemaMap(leaf["parameters"]) {
raw["future_mapping_field"] = "must not leak into Agent view"
}
stripped := stripSchemaPayloadCompact(leaf)
// Must keep agent-essential fields.
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
}
// Must strip provenance / redundant fields.
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
if _, ok := stripped[key]; ok {
t.Fatalf("compact leaf still contains stripped key %q", key)
}
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if params, ok := stripped["parameters"].(map[string]any); ok {
for name, p := range params {
if pm, ok := p.(map[string]any); ok {
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
if _, present := pm[stripped]; present {
t.Fatalf("compact param %q still contains %q", name, stripped)
}
@@ -1412,3 +1416,57 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
}
}
}
// TestDeliveryCatalogDingSemanticParamDeclsSpotCheck locks the ding pilot
// ParamDecl backfill: Execute/CallMCP keys win over flag-name inference, and
// required stays declare/cobra-backed rather than usage_required_inference.
func TestDeliveryCatalogDingSemanticParamDeclsSpotCheck(t *testing.T) {
cases := []struct {
path string
flag string
property string
required bool
}{
{"ding +list", "cursor", "cursor", false},
{"ding +list", "type", "type", false},
{"ding +receiver-status", "ding-id", "openDingId", true},
{"ding +recall-personal", "id", "openDingId", true},
{"ding +send-personal", "users", "receiverOpenDingTalkIds", true},
{"ding +send-personal", "content", "content", true},
{"ding +send-personal", "type", "remindType", false},
{"ding +send-personal", "uuid", "uuid", false},
{"ding message send", "content", "content", true},
{"ding message send", "robot-code", "robotCode", true},
{"ding message send", "type", "remindType", false},
{"ding message send", "users", "receiverUserIdList", true},
{"ding message recall", "id", "openDingId", true},
{"ding message recall", "robot-code", "robotCode", true},
}
for _, tc := range cases {
leaf, err := queryDeliverySchemaPayload([]string{tc.path})
if err != nil {
t.Fatalf("%s: %v", tc.path, err)
}
param := schemaMap(leaf["parameters"])[tc.flag]
if param == nil {
t.Fatalf("%s missing --%s", tc.path, tc.flag)
}
if got, _ := param["property"].(string); got != tc.property {
t.Fatalf("%s --%s property = %q, want %q", tc.path, tc.flag, got, tc.property)
}
propProv := schemaMap(param["field_provenance"])["property"]
if src, _ := propProv["source"].(string); src == "flag_name_inference" {
t.Fatalf("%s --%s property still flag_name_inference", tc.path, tc.flag)
}
gotRequired, _ := param["required"].(bool)
if gotRequired != tc.required {
t.Fatalf("%s --%s required = %v, want %v", tc.path, tc.flag, gotRequired, tc.required)
}
if tc.required {
reqProv := schemaMap(param["field_provenance"])["required"]
if src, _ := reqProv["source"].(string); src == "usage_required_inference" {
t.Fatalf("%s --%s required still usage_required_inference", tc.path, tc.flag)
}
}
}
}
@@ -448,7 +448,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -656,6 +655,7 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
+2 -179
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,184 +2082,7 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+85 -27
View File
@@ -54,6 +54,14 @@ 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) {
@@ -396,6 +404,50 @@ 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)
@@ -2029,29 +2081,15 @@ 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,
}
if atAll {
newParams["atAll"] = true
}
if len(atOpenIds) > 0 {
newParams["atOpenDingTalkIds"] = atOpenIds
}
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams["content"] = string(contentJSON)
if msgUuid != "" {
newParams["uuid"] = msgUuid
}
@@ -5368,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageReplyCmd := &cobra.Command{
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
如何获取 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 "收到,马上处理"`,
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>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
@@ -5387,13 +5426,6 @@ 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 {
@@ -5402,9 +5434,25 @@ 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
}
@@ -5438,6 +5486,8 @@ 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"},
},
},
@@ -5452,6 +5502,8 @@ 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: 转发单条消息 ────────────────────────
@@ -8166,5 +8218,11 @@ 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>"))
return root
}
+21 -12
View File
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
})
}
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
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
flag string
args []string
hint string
}{
{path: "send", flag: "--group"},
{path: "history", flag: "--group"},
{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>"},
} {
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("chat %s was misreported as a flag error: %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 caller.calls != 0 {
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
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)
}
}
}
@@ -15,6 +15,7 @@ package helpers
import (
"context"
"encoding/json"
"io"
"os"
"reflect"
@@ -285,6 +286,150 @@ 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")
-282
View File
@@ -1,282 +0,0 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file is adapted from Go standard library's internal/diff package.
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
//
// Modifications:
// - Package changed from "diff" to "products"
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
// - const C replaced with the contextLines parameter
package helpers
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type diffPair struct{ x, y int }
func nonNeg(v int) int {
if v < 0 {
return 0
}
return v
}
// UnifiedDiff returns an anchored diff of the two texts old and new
// in the "unified diff" format. If old and new are identical,
// UnifiedDiff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of "unique" lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an "anchored diff" because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a "patience diff," named for
// the "patience sorting" algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
if bytes.Equal(old, new) {
return nil
}
x := diffLines(old)
y := diffLines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done diffPair // printed up to x[:done.x] and y[:done.y]
chunk diffPair // start lines of current chunk
count diffPair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range diffTgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far as possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitely do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := contextLines
if C < 0 {
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
C = 0
}
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = diffPair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// diffLines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func diffLines(x []byte) []string {
l := strings.SplitAfter(string(x), "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// diffTgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, "A Special Case of the Maximal Common
// Subsequence Problem," Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func diffTgs(x, y []string) []diffPair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]diffPair, 2+k)
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = diffPair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = diffPair{0, 0} // sentinel at start
return seq
}
+6 -3
View File
@@ -115,8 +115,8 @@ func newDingCommand() *cobra.Command {
},
},
Parameters: []contract.ParamDecl{
{Name: "content", Required: boolPtr(true)},
{Name: "robot-code", Required: boolPtr(true)},
{Name: "content", Property: "content", Required: boolPtr(true)},
{Name: "robot-code", Property: "robotCode", Required: boolPtr(true)},
{Name: "type", Property: "remindType"},
{Name: "users", Property: "receiverUserIdList", Required: boolPtr(true), InterfaceType: "array"},
},
@@ -170,7 +170,10 @@ func newDingCommand() *cobra.Command {
Examples: []string{"dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "openDingId"},
// id is validateRequiredFlags-hard; robot-code is required unless
// DINGTALK_DING_ROBOT_CODE is set (same Agent-facing contract as send).
{Name: "id", Property: "openDingId", Required: boolPtr(true)},
{Name: "robot-code", Property: "robotCode", Required: boolPtr(true)},
},
},
})
+4 -11
View File
@@ -3657,13 +3657,11 @@ CLI 内部自动完成全部流程:
PROCESSING 处理中
SUCCESS 导出成功,返回 downloadUrl
FAILED 导出失败`,
Example: ` dws doc export get --job-id <JOB_ID>
dws doc export get --task-id <TASK_ID>`,
Example: ` dws doc export get --job-id <JOB_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
if err != nil {
return err
jobID := mustGetFlag(cmd, "job-id")
if jobID == "" {
return fmt.Errorf("flag --job-id is required")
}
if deps.Caller.DryRun() {
@@ -3730,14 +3728,9 @@ CLI 内部自动完成全部流程:
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "job-id", Property: "jobId"},
},
},
})
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
_ = exportGetCmd.Flags().MarkHidden("task-id")
// --node 的隐藏别名(与 doc 下其他命令保持一致)
exportCmd.Flags().String("url", "", "--node 的别名")
+7 -72
View File
@@ -316,24 +316,12 @@ func newDriveCommand() *cobra.Command {
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
dws drive list --workspace <workspaceId> --folder <folderId>`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -375,7 +363,7 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 || latest > 0 {
if depth > 1 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -384,7 +372,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
if pattern != "" {
return &CLIError{
@@ -429,27 +417,10 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
// 默认路由:钉盘文件列表
if latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
@@ -520,7 +491,7 @@ func newDriveCommand() *cobra.Command {
},
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --latest 5 --format json",
"dws drive list --folder <dentryUuid> --limit 20 --format json",
},
},
},
@@ -730,8 +701,6 @@ func newDriveCommand() *cobra.Command {
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
@@ -1083,8 +1052,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -1092,7 +1060,6 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
@@ -1107,14 +1074,6 @@ func newDriveCommand() *cobra.Command {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
@@ -2060,8 +2019,6 @@ func newDriveCommand() *cobra.Command {
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
toolArgs["maxResults"] = limit
@@ -2113,8 +2070,6 @@ func newDriveCommand() *cobra.Command {
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
@@ -2778,7 +2733,7 @@ func newDriveCommand() *cobra.Command {
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
@@ -3080,11 +3035,6 @@ func newDriveCommand() *cobra.Command {
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
@@ -3137,21 +3087,6 @@ func newDriveCommand() *cobra.Command {
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveCmd.AddCommand(
driveListCmd,
+18 -39
View File
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +220,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +233,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern)
}
if err != nil {
folderErr = err
@@ -249,12 +249,6 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
if ms, ok := driveItemModifiedMillis(item); ok {
item["sortTime"] = ms
} else {
item["sortTime"] = int64(0)
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
@@ -297,7 +291,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
return emitErr
}
return folderErr
@@ -330,26 +324,18 @@ bfs:
}
}
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
return emitDriveDepthResult(collected, errs, truncated, pattern)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -364,28 +350,21 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
if latest > 0 {
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
items = []map[string]any{}
}
+17 -17
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
return decodeDepthResult(t, out), err
}
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("root failure returned nil")
}
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("unrecoverable returned nil")
}
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
-199
View File
@@ -1,199 +0,0 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页)。
driveLatestScanMax = 1000
)
// validateDriveListLatest --latest 边界与互斥校验。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveLatestExclusiveError("limit", latest)
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveLatestExclusiveError("cursor", latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
if v, ok := item[k]; ok {
if ms, ok := toMillis(v); ok {
return ms, true
}
}
}
return 0, false
}
func toMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
// runDriveListLatest 钉盘单层 --latest 扫描。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
+3 -1
View File
@@ -66,6 +66,8 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
if driveWorkerContextErr(runCtx) != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+25 -39
View File
@@ -13,6 +13,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
// 通过结构化 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) {
// 每次请求加小延迟,让总处理时间接近 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
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
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)
}
// 多次尝试以确保覆盖(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()
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())
}
}
+3 -489
View File
@@ -195,72 +195,7 @@ func newMailCommand() *cobra.Command {
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxSharedWithMeCmd := &cobra.Command{
Use: "shared-with-me",
Short: "查询共享给我的邮箱",
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
共享关系(relationships)取值:
LOGIN 登录(可登录该共享邮箱)
SEND_AS 代发(以该邮箱身份发送邮件)
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
返回字段:
total 可访问的共享账号总数
targets 可访问的共享账号列表`,
Example: ` dws mail mailbox shared-with-me
dws mail mailbox shared-with-me --limit 20 --offset 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if cmd.Flags().Changed("limit") {
limit, _ := cmd.Flags().GetInt("limit")
toolArgs["limit"] = limit
}
if cmd.Flags().Changed("offset") {
offset, _ := cmd.Flags().GetInt("offset")
toolArgs["offset"] = offset
}
if len(toolArgs) == 0 {
return callMCPTool("list_shared_with_me", nil)
}
return callMCPTool("list_shared_with_me", toolArgs)
},
}
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_shared_with_me",
CanonicalPath: "mail.list_shared_with_me",
CLIPath: "mail mailbox shared-with-me",
PrimaryCLIPath: "mail mailbox shared-with-me",
},
Description: "查询共享给我的邮箱",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询他人共享给当前用户的邮箱",
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
Examples: []string{"dws mail mailbox shared-with-me"},
},
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "limit", InterfaceType: "integer"},
{Name: "offset", Property: "offset", InterfaceType: "integer"},
},
},
})
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -2191,251 +2126,9 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageExportCmd := &cobra.Command{
Use: "export",
Short: "导出/备份邮件(EML格式)",
Long: `导出指定邮件为 EML 格式文件并保存到本地。
不指定 --filename 时,默认以邮件主题作为文件名。
文件保存在当前工作目录下,扩展名为 .eml。
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
注意:目前仅支持 100KB 以内的邮件导出。
编排流程:
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
2. 调用 export_message_mime 获取 EML 内容
3. 将 EML 内容原子写入本地文件`,
Example: ` dws mail message export --email user@company.com --id <messageId>
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
messageID := mustGetFlag(cmd, "id")
filename := mustGetFlag(cmd, "filename")
overwrite, _ := cmd.Flags().GetBool("overwrite")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
deps.Out.PrintKeyValue("email", email)
deps.Out.PrintKeyValue("messageId", messageID)
if filename != "" {
deps.Out.PrintKeyValue("filename", filename)
}
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
return nil
}
ctx := cmd.Context()
if filename == "" {
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
"email": email,
"messageId": messageID,
})
if err != nil {
return fmt.Errorf("获取邮件信息失败: %w", err)
}
var msgData map[string]any
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
data := msgData
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if msg, ok := data["message"].(map[string]any); ok {
data = msg
}
if subj, ok := data["subject"].(string); ok && subj != "" {
filename = subj
}
}
if filename == "" {
filename = messageID
}
}
filename = sanitizeMailFilename(filename)
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
"email": email,
"id": messageID,
})
if err != nil {
return fmt.Errorf("导出邮件失败: %w", err)
}
var exportData map[string]any
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
return fmt.Errorf("解析导出结果失败: %w", err)
}
if result, ok := exportData["result"].(map[string]any); ok {
exportData = result
}
emlContent, _ := exportData["emlContent"].(string)
if emlContent == "" {
return fmt.Errorf("导出结果为空: %s", exportText)
}
destPath := filename + ".eml"
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
if os.IsExist(err) {
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
}
return fmt.Errorf("保存文件失败: %w", err)
}
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
return nil
},
}
DeclareLeafMetadata(messageExportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "export_message_mime",
CanonicalPath: "mail.export_message_mime",
CLIPath: "mail message export",
PrimaryCLIPath: "mail message export",
},
Description: "导出/备份邮件为本地 EML 文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
},
Selection: contract.SelectionSpec{
AgentSummary: "导出邮件为本地 EML 备份",
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "filename", Property: "filename"},
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
},
},
})
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
messageShareToChatCmd := &cobra.Command{
Use: "share-to-chat",
Short: "[危险] 分享邮件至IM聊天",
Long: `将指定邮件分享到钉钉单聊。
参数说明:
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
--yes 跳过二次确认,直接执行分享
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
mcpArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
}
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
mcpArgs["uids"] = parseRecipients(users)
}
skipConfirm, _ := cmd.Flags().GetBool("yes")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
deps.Out.PrintKeyValue("users", users)
}
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
return nil
}
ctx := cmd.Context()
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
if err != nil {
return fmt.Errorf("分享邮件失败: %w", err)
}
var firstResult map[string]any
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
return fmt.Errorf("解析分享结果失败: %w", err)
}
if result, ok := firstResult["result"].(map[string]any); ok {
firstResult = result
}
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
riskMsg, _ := firstResult["riskMessage"].(string)
if !skipConfirm {
if riskMsg != "" {
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
}
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
}
mcpArgs["sign"] = sign
return callMCPTool("share_message_to_chat", mcpArgs)
}
// firstResult is already a parsed object (possibly unwrapped from result).
return deps.Out.PrintJSON(firstResult)
},
}
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "share_message_to_chat",
CanonicalPath: "mail.share_message_to_chat",
CLIPath: "mail message share-to-chat",
PrimaryCLIPath: "mail message share-to-chat",
},
Description: "分享邮件至 IM 单聊",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
},
Selection: contract.SelectionSpec{
AgentSummary: "把邮件分享到钉钉单聊",
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "users", Property: "uids"},
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
},
},
})
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
@@ -3707,129 +3400,7 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
Example: ` dws mail calendar list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_calendars", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
DeclareLeafMetadata(calendarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendars",
CanonicalPath: "mail.list_mailbox_calendars",
CLIPath: "mail calendar list",
PrimaryCLIPath: "mail calendar list",
},
Description: "列出邮箱日历文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
},
Selection: contract.SelectionSpec{
AgentSummary: "列出邮箱可访问的日历文件夹",
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
Examples: []string{"dws mail calendar list --email user@company.com"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
},
},
})
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
return err
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": flagOrFallback(cmd, "id", "folder-id"),
"startTime": flagOrFallback(cmd, "start", "start-time"),
"endTime": flagOrFallback(cmd, "end", "end-time"),
}
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
toolArgs["cursor"] = cursor
}
return callMCPTool("list_mailbox_calendar_events", toolArgs)
},
}
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendar_events",
CanonicalPath: "mail.list_mailbox_calendar_events",
CLIPath: "mail calendar-event list",
PrimaryCLIPath: "mail calendar-event list",
},
Description: "查询邮箱日历文件夹时间范围内的日程",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询邮箱日历日程列表",
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "start", Property: "startTime", Required: boolPtr(true)},
{Name: "end", Property: "endTime", Required: boolPtr(true)},
{Name: "cursor", Property: "cursor"},
},
},
})
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
calendarEventCmd.AddCommand(calendarEventListCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
return root
}
@@ -4364,60 +3935,3 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
return nil
}
func sanitizeMailFilename(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "mail"
}
return name
}
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
var mailAtomicLink = os.Link
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
dir := filepath.Dir(path)
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = atomicRemove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("设置文件权限失败: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("写入数据失败: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("同步磁盘失败: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("关闭临时文件失败: %w", err)
}
if overwrite {
if err := atomicRename(tmpName, path); err != nil {
return fmt.Errorf("重命名文件失败: %w", err)
}
success = true
return nil
}
if err := mailAtomicLink(tmpName, path); err != nil {
return err
}
_ = atomicRemove(tmpName)
success = true
return nil
}
+1 -2
View File
@@ -49,13 +49,12 @@ func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
newMarkdownDiffCmd(),
newMarkdownOverwriteCmd(),
newMarkdownPatchCmd(),
)
-491
View File
@@ -1,491 +0,0 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws markdown diff
// ──────────────────────────────────────────────────────────
// diffResult 是 diff 命令的输出结构。
type diffResult struct {
Mode string `json:"mode"`
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks int `json:"hunks"`
Diff string `json:"diff"`
}
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
var (
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = computeUnifiedDiff
)
// formatFileSize 返回人类可读的文件大小。
func formatFileSize(size int64) string {
if size >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
}
if size >= 1024 {
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
}
return fmt.Sprintf("%d B", size)
}
// checkFileSize 校验文件大小是否超过限制。
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxDiffFileSize {
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
}
return nil
}
// downloadRemoteContent 下载远端文件内容并返回其文本。
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
var text string
var err error
if versionNum > 0 {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
} else {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
"fileId": fileID,
})
}
if err != nil {
return "", err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return "", err
}
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
if err != nil {
return "", err
}
return string(content), nil
}
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
// 包级变量以便测试注入。
var diffDownloadLimited = defaultDiffDownloadLimited
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: diffDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
// Content-Length 预检:可在读取 body 前提前拦截超大文件
if resp.ContentLength > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
}
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
}
return data, nil
}
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
if len(out) == 0 {
return "", 0, 0, 0, false
}
text := string(out)
added, deleted, hunks := 0, 0, 0
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
inHunk := false
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "@@") {
hunks++
inHunk = true
continue
}
if !inHunk {
continue
}
if strings.HasPrefix(line, "+") {
added++
} else if strings.HasPrefix(line, "-") {
deleted++
}
}
changed := added > 0 || deleted > 0
return text, added, deleted, hunks, changed
}
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
info := fetchFileInfo(ctx, nodeID)
switch info.extension {
case "", "md", "markdown":
return nil
case "adoc":
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
case "axls":
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
case "amind", "adraw":
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
default:
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
}
}
// newMarkdownDiffCmd 创建 markdown diff 子命令。
func newMarkdownDiffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "diff",
Short: "比较 Markdown 内容差异",
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
模式:
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
remote_vs_remote: --version V1 (历史版本 vs 最新)
remote_vs_local: --file ./local.md (最新 vs 本地)
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
历史版本号通过 dws drive list --versions 获取。
--file 与 --version2 不能同时使用。
限制:
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
- 单侧文件大小上限: 10 MB
- 下载超时: 10 分钟
- diff 计算超时: 30 秒`,
Example: ` # 比较两个历史版本
dws markdown diff --node <dentryUuid> --version 3 --version2 5
# 历史版本 vs 最新版本
dws markdown diff --node <dentryUuid> --version 3
# 最新版本 vs 本地文件
dws markdown diff --node <dentryUuid> --file ./draft.md
# 历史版本 vs 本地文件
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
version1, _ := cmd.Flags().GetInt("version")
version2, _ := cmd.Flags().GetInt("version2")
localFile, _ := cmd.Flags().GetString("file")
contextLines, _ := cmd.Flags().GetInt("context")
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
if cmd.Flags().Changed("version") && version1 <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
}
if cmd.Flags().Changed("version2") && version2 <= 0 {
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
}
if contextLines < 0 {
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
}
// 互斥校验:--file 与 --version2 不能同时使用
if localFile != "" && version2 > 0 {
return fmt.Errorf("--file 与 --version2 不能同时使用")
}
// 模式判定
var mode string
if localFile != "" {
mode = "remote_vs_local"
} else {
mode = "remote_vs_remote"
}
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
if localFile == "" && version1 == 0 && version2 == 0 {
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
deps.Out.PrintKeyValue("模式", mode)
deps.Out.PrintKeyValue("节点ID", nodeID)
if version1 > 0 {
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
} else {
deps.Out.PrintKeyValue("左侧版本", "最新")
}
if localFile != "" {
deps.Out.PrintKeyValue("右侧", localFile)
} else if version2 > 0 {
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
} else {
deps.Out.PrintKeyValue("右侧版本", "最新")
}
return nil
}
// 下载超时 context(与项目其他下载命令一致:10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
defer cancel()
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
return err
}
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
if localFile != "" {
if err := checkFileSize(localFile); err != nil {
return err
}
}
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
isJSON := deps.Caller.Format() == "json"
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
// 下载左侧内容
progress("[1/3] 获取左侧内容...")
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
if err != nil {
return err
}
// 获取右侧内容
var rightContent string
if localFile != "" {
// remote_vs_local: 读取本地文件(大小已校验)
progress("[2/3] 读取本地文件...")
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("读取本地文件失败: %w", err)
}
rightContent = string(data)
} else {
// remote_vs_remote: 下载右侧远端内容
progress("[2/3] 获取右侧内容...")
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
if err != nil {
return err
}
}
// 计算 diff(带超时保护)
progress("[3/3] 计算差异...")
type diffOutput struct {
text string
added int
deleted int
hunks int
changed bool
}
resultCh := make(chan diffOutput, 1)
// Capture seams before the goroutine so test restorers cannot race
// against a still-running compute after the timeout path returns.
computeDiff := runMarkdownUnifiedDiff
marshalIndent := diffJSONMarshalIndent
go func() {
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
resultCh <- diffOutput{text, added, deleted, hunks, changed}
}()
select {
case res := <-resultCh:
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
result := diffResult{
Mode: mode,
Changed: changed,
AddedLines: added,
DeletedLines: deleted,
Hunks: hunks,
Diff: diffText,
}
// 输出
if isJSON {
data, err := marshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
deps.Out.PrintRaw(string(data))
} else {
deps.Out.PrintKeyValue("模式", result.Mode)
if result.Changed {
deps.Out.PrintKeyValue("是否有变更", "是")
} else {
deps.Out.PrintKeyValue("是否有变更", "否")
}
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
if result.Changed {
deps.Out.PrintRaw("")
deps.Out.PrintRaw(result.Diff)
}
}
case <-time.After(diffComputeTimeout):
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
}
return nil
},
}
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
// --node 隐藏别名(与 version/fetch 子命令一致)
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
_ = cmd.Flags().MarkHidden("url")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("node-id")
_ = cmd.Flags().MarkHidden("doc-id")
_ = cmd.Flags().MarkHidden("file-id")
RegisterCrossProductAliases(cmd)
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "markdown",
Name: "diff",
CanonicalPath: "markdown.diff",
CLIPath: "markdown diff",
PrimaryCLIPath: "markdown diff",
},
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "version", Property: "version", InterfaceType: "integer"},
{Name: "version2", Property: "version2", InterfaceType: "integer"},
{Name: "file", Property: "file"},
{Name: "context", Property: "context", InterfaceType: "integer"},
},
},
})
return cmd
}
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
// 探测失败返回零值,由调用方决定是否阻断。
type markdownFileInfo struct {
name string
extension string
}
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
return
}
data := resp
if result, ok := resp["result"].(map[string]any); ok {
data = result
}
if name, ok := data["name"].(string); ok {
info.name = name
}
if ext, ok := data["extension"].(string); ok {
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
}
return
}
func describeDingTalkDocType(ext string) string {
switch strings.ToLower(ext) {
case "adoc":
return "文档"
case "axls":
return "表格"
case "amind":
return "脑图"
case "adraw":
return "画图"
default:
return "文件"
}
}
+10 -260
View File
@@ -3,7 +3,6 @@ package helpers
import (
"fmt"
"strconv"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -19,7 +18,7 @@ func newMinutesCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "minutes",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
UseWhen: []string{
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
},
@@ -1283,10 +1282,7 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
AvoidWhen: []string{
"要查看已有热词时改用 dws minutes hot-word list",
"要删除热词时改用 dws minutes hot-word delete",
},
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
Examples: []string{
"dws minutes hot-word add --words \"钉钉\"",
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
@@ -1332,70 +1328,13 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户配置的所有听记热词列表。",
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"要删除热词时改用 hot-word delete",
},
Examples: []string{"dws minutes hot-word list"},
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
Examples: []string{"dws minutes hot-word list"},
},
},
})
hotWordDeleteCmd := &cobra.Command{
Use: "delete",
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
return err
}
return callMCPTool("delete_personal_hotword", map[string]any{
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
})
},
}
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "delete_personal_hotword",
CanonicalPath: "minutes.delete_personal_hotword",
CLIPath: "minutes hot-word delete",
PrimaryCLIPath: "minutes hot-word delete",
},
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "words", Property: "hotWordList"},
},
},
})
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
// ── replace-text 命令 ───────────────────────────────────────
replaceTextCmd := &cobra.Command{
@@ -1679,8 +1618,8 @@ func newMinutesCommand() *cobra.Command {
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
// 听记成员权限管理:批量添加/移除成员及其权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
// permission add — 对应 MCP 工具 add_member_permission
@@ -1770,7 +1709,6 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
AvoidWhen: []string{
"要移除成员权限时改用 dws minutes permission remove",
"当前用户自己申请访问权限时改用 dws minutes permission apply",
"成员、权限策略或听记 id 未确认时不要添加",
},
Examples: []string{
@@ -1846,7 +1784,6 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
AvoidWhen: []string{
"要添加权限时改用 permission add",
"当前用户自己申请访问权限时改用 permission apply",
"成员或听记 id 未确认时不要移除",
},
Examples: []string{
@@ -1866,85 +1803,7 @@ func newMinutesCommand() *cobra.Command {
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
// permission apply — 对应 MCP 工具 apply_minutes_permission
permissionApplyCmd := &cobra.Command{
Use: "apply",
Short: "为当前用户申请听记权限",
Long: `为当前登录用户申请指定听记的权限。
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
权限类型 (--policy):
2 = 可编辑
3 = 可查看/下载
4 = 仅查看`,
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
dws minutes permission apply --id <taskUuid> --policy 2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
return callMCPTool("apply_minutes_permission", map[string]any{
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
"policyId": float64(policyID),
})
},
}
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "apply_minutes_permission",
CanonicalPath: "minutes.apply_minutes_permission",
CLIPath: "minutes permission apply",
PrimaryCLIPath: "minutes permission apply",
},
Description: "为当前登录用户申请指定听记的权限。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
AvoidWhen: []string{
"所有者批量给他人加权限时改用 permission add",
"要移除他人权限时改用 permission remove",
},
Examples: []string{
"dws minutes permission apply --id <taskUuid> --policy 4",
"dws minutes permission apply --id <taskUuid> --policy 2",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "taskUuid"},
{Name: "policy", Property: "policyId"},
},
},
})
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("url")
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
// ── tag 子组 ────────────────────────────────────────────────
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
@@ -2069,122 +1928,13 @@ func newMinutesCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
// ── audio-memo 子组 ────────────────────────────
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoListCmd := &cobra.Command{
Use: "list",
Short: "查询语音备忘列表",
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
Example: ` dws minutes audio-memo list
dws minutes audio-memo list --max 500
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
dws minutes audio-memo list --cursor 1740000000000`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
max, _ := cmd.Flags().GetFloat64("max")
if max <= 0 || max > 1000 {
return fmt.Errorf("flag --max must be between 1 and 1000")
}
toolArgs["pageSize"] = max
if cmd.Flags().Changed("cursor") {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return fmt.Errorf("flag --cursor must be >= 0")
}
toolArgs["cursor"] = float64(cursor)
}
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
loc := time.FixedZone("Asia/Shanghai", 8*3600)
var startMs, endMs int64
if startStr != "" {
var err error
startMs, err = parseISOTimeToMillis("start", startStr)
if err != nil {
return err
}
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
}
if endStr != "" {
var err error
endMs, err = parseISOTimeToMillis("end", endStr)
if err != nil {
return err
}
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
}
if startStr != "" && endStr != "" {
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
}
return callMCPToolUnescaped("list_audio_memos", toolArgs)
},
}
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "list_audio_memos",
CanonicalPath: "minutes.list_audio_memos",
CLIPath: "minutes audio-memo list",
PrimaryCLIPath: "minutes audio-memo list",
},
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
AvoidWhen: []string{
"要查听记列表改用 minutes list",
"只要某篇听记的音频地址改用 minutes get audio",
},
Examples: []string{
"dws minutes audio-memo list",
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "max", Property: "pageSize"},
{Name: "cursor", Property: "cursor"},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
},
},
})
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
@@ -1,709 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeMarkdownDiff(t *testing.T, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "markdown"}, args...)
root := newMarkdownCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
t.Run("oversized local file", func(t *testing.T) {
testseam.Swap(t, &maxDiffFileSize, int64(8))
big := filepath.Join(t.TempDir(), "big.md")
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(big); err == nil {
t.Fatal("expected oversized local file")
}
})
t.Run("download helpers and ensure type", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("left\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
t.Fatalf("download latest: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
t.Fatalf("download version: %v", err)
}
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return nil, errors.New("dl boom")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
t.Fatal("expected download limited error")
}
for _, tc := range []struct {
ext string
ok bool
}{
{"md", true}, {"markdown", true}, {"", true},
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
} {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
}})
err := ensureMarkdownDiffType(context.Background(), "n1")
if tc.ok && err != nil {
t.Fatalf("ext %q: %v", tc.ext, err)
}
if !tc.ok && err == nil {
t.Fatalf("ext %q: expected type error", tc.ext)
}
}
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
if describeDingTalkDocType(ext) == "" {
t.Fatalf("describe %q empty", ext)
}
}
})
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(okSrv.Close)
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
if err != nil || string(got) != "data" {
t.Fatalf("ok download: %v %q", err, got)
}
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("nope"))
}))
t.Cleanup(badStatus.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
t.Fatal("expected non-200")
}
testseam.Swap(t, &maxDiffFileSize, int64(3))
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("xxxx"))
}))
t.Cleanup(tooBigHeader.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
t.Fatal("expected content-length guard")
}
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// No Content-Length: force LimitReader path to observe oversize body.
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "no hijack", 500)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
payload := strings.Repeat("x", 16)
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
_ = bufrw.Flush()
}))
t.Cleanup(chunkedBig.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
t.Fatal("expected body size guard")
}
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
_ = bufrw.Flush()
_ = conn.Close() // truncate body → ReadAll error
}))
t.Cleanup(readFail.Close)
maxDiffFileSize = 1000
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
t.Fatal("expected read error")
}
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
t.Fatal("expected bad url")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
t.Fatal("expected canceled ctx")
}
})
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
}
})
t.Run("validation and dry-run modes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeMarkdownDiff(t, "diff"); err == nil {
t.Fatal("expected missing node")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
t.Fatal("expected version>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
t.Fatal("expected version2>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
t.Fatal("expected context>=0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
t.Fatal("expected file/version2 mutex")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
t.Fatal("expected remote needs version")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
t.Fatalf("dry-run remote: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local latest: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local version: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
t.Fatalf("dry-run version2-only: %v", err)
}
})
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("alpha\n"), nil
}
local := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
t.Fatal(err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
t.Fatalf("local json: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
if strings.Contains(url, "/r") {
return []byte("alpha\n"), nil
}
return []byte("gamma\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text changed: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("same\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text unchanged: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"adoc"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
t.Fatal("expected type guard")
}
testseam.Protect(t, &maxDiffFileSize)
maxDiffFileSize = 2
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
t.Fatal("expected local size fail")
}
maxDiffFileSize = 10 * 1024 * 1024
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
missingLocal := filepath.Join(t.TempDir(), "missing.md")
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
// Create file for size check, then make ReadFile fail via directory path.
dirAsFile := t.TempDir()
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
t.Fatal("expected read local dir failure")
}
_ = missingLocal
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{err: errors.New("right fail")},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
// second download uses version tool — make parse fail on second call via empty resource
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
t.Fatal("expected right download parse fail")
}
})
t.Run("json marshal and compute timeout", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
testseam.Protect(t, &diffJSONMarshalIndent)
testseam.Protect(t, &runMarkdownUnifiedDiff)
testseam.Protect(t, &diffComputeTimeout)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("a\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
return nil, errors.New("marshal boom")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
t.Fatalf("expected marshal err, got %v", err)
}
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
time.Sleep(50 * time.Millisecond)
return "", 0, 0, 0, false
}
diffComputeTimeout = time.Millisecond
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("expected timeout, got %v", err)
}
})
t.Run("fetchFileInfo extension field", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":".MD","name":"x.md"}`},
}})
info := fetchFileInfo(context.Background(), "n")
if info.extension != "md" || info.name != "x.md" {
t.Fatalf("info=%+v", info)
}
})
}
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
t.Run("message export dry-run and execute", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
t.Fatalf("export dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export dry-run default name: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export: %v", err)
}
if _, err := os.Stat("Hello_World.eml"); err != nil {
t.Fatalf("missing eml: %v", err)
}
// exist without overwrite
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"x"}`},
}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
t.Fatal("expected exist error")
}
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"y"}`},
}}
installScriptedCaller(t, caller3)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
t.Fatalf("overwrite: %v", err)
}
// subject fallback to message id when missing
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{}}`},
{text: `{"emlContent":"z"}`},
}}
installScriptedCaller(t, caller4)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
t.Fatalf("fallback name: %v", err)
}
})
t.Run("share-to-chat paths", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
t.Fatalf("share dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("share dry-run no users: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
t.Fatal("expected parse error")
}
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
// so skipConfirm=false and the server-sign reconfirm branch runs.
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected server reconfirm, got %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig"}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig","riskMessage":"careful"}`},
{text: `{"ok":true}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("share with yes: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("json success path: %v", err)
}
})
t.Run("calendar-event missing folder id", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "u@c.com",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
); err == nil {
t.Fatal("expected missing id/folder-id")
}
})
t.Run("sanitize and atomic write", func(t *testing.T) {
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
t.Fatalf("sanitize=%q", got)
}
if got := sanitizeMailFilename(" "); got != "mail" {
t.Fatalf("empty sanitize=%q", got)
}
dir := t.TempDir()
path := filepath.Join(dir, "out.eml")
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
t.Fatal("expected exist without overwrite")
}
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
t.Fatal("expected create temp fail")
}
testseam.Protect(t, &atomicCreateTemp)
testseam.Protect(t, &atomicRemove)
testseam.Protect(t, &atomicRename)
atomicRemove = func(string) error { return nil }
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("chmod: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
t.Fatalf("write: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
t.Fatalf("sync: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
t.Fatalf("close: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{}, nil
}
atomicRename = func(string, string) error { return errors.New("rename boom") }
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
t.Fatalf("rename: %v", err)
}
})
t.Run("export save non-exist failure", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
return nil, errors.New("nospc")
})
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"body"}`},
}})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
t.Fatalf("expected save failure, got %v", err)
}
})
}
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetIn(in)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
// Expand matches backward + negative context clamp + missing newline marker.
old := []byte("a\nb\nc\nd\ne")
neu := []byte("a\nb\nC\nd\ne\n")
diff := UnifiedDiff("o", old, "n", neu, -3)
if len(diff) == 0 {
t.Fatal("expected diff with negative context")
}
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
t.Fatalf("unexpected diff2=%q", diff2)
}
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
// Non-unique "common" lines before a unique anchor → backward expand (L90).
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
t.Fatalf("nonNeg")
}
}
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
items := []map[string]any{
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
t.Fatal(err)
}
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
t.Fatalf("json.Number millis=%v %v", ms, ok)
}
if _, ok := toMillis(json.Number("-1")); ok {
t.Fatal("negative json.Number")
}
if _, ok := toMillis(struct{}{}); ok {
t.Fatal("unknown type")
}
// pagination + quiet=false progress + shortfall hint with pattern
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller)
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "drive", "list"}
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
t.Fatalf("latest paginate: %v", err)
}
// nil context uses Background
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller2)
cmd2 := &cobra.Command{Use: "list"}
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
t.Fatalf("nil ctx: %v", err)
}
}
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
t.Fatal("expected numeric folder rejection")
}
}
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
useDriveDepthArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
_ = out
}
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
if buildWhiteboardCardJSONML("b", "w") != "" {
t.Fatal("expected empty on marshal fail")
}
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
return "", errors.New("bad template")
})
installScriptedCaller(t, &scriptedToolCaller{})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
t.Fatalf("expected prepare fail, got %v", err)
}
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "doc", "whiteboard"}
// nil entry in blocks list
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
t.Fatalf("nil entry skip: %v", err)
}
}
@@ -1,488 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"io"
"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"
"github.com/spf13/cobra"
)
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", root.Name()}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
t.Fatalf("hot-word delete dry-run: %v", err)
}
})
t.Run("hot-word delete missing words", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
t.Fatal("expected missing --words error")
}
})
t.Run("hot-word delete executes", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
t.Fatalf("hot-word delete: %v", err)
}
if caller.tool != "delete_personal_hotword" {
t.Fatalf("tool=%q", caller.tool)
}
})
t.Run("permission apply dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
t.Fatalf("permission apply dry-run: %v", err)
}
})
t.Run("permission apply alias uuid", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
t.Fatalf("permission apply alias: %v", err)
}
if caller.tool != "apply_minutes_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
t.Fatalf("args=%#v", caller.args)
}
})
t.Run("permission apply invalid policy", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
t.Fatal("expected invalid policy")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
t.Fatal("expected non-numeric policy")
}
})
t.Run("permission apply missing flags", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
t.Fatal("expected missing id")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
t.Fatal("expected missing policy")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
t.Fatalf("audio-memo list: %v", err)
}
if caller.tool != "list_audio_memos" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != float64(200) {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
})
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(),
"audio-memo", "list",
"--max", "10",
"--cursor", "1740000000000",
"--start", "2026-01-01T00:00:00+08:00",
"--end", "2026-07-21T23:59:59+08:00",
)
if err != nil {
t.Fatalf("audio-memo list ranged: %v", err)
}
if caller.args["cursor"] != float64(1740000000000) {
t.Fatalf("cursor=%#v", caller.args["cursor"])
}
start, _ := caller.args["startTime"].(string)
end, _ := caller.args["endTime"].(string)
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
t.Fatalf("start/end=%q/%q", start, end)
}
})
t.Run("audio-memo list validation", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
t.Fatal("expected max validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
t.Fatal("expected max upper bound")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
t.Fatal("expected cursor validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
t.Fatal("expected reversed range error")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
t.Fatal("expected bad start")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
t.Fatal("expected bad end")
}
})
}
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
// Existing primary --job-id must remain usable.
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
t.Fatalf("export get --job-id: %v", err)
}
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
}
// Add-only synonym --task-id.
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
t.Fatalf("export get --task-id: %v", err)
}
if caller2.args["jobId"] != "job-123" {
t.Fatalf("task-id args=%#v", caller2.args)
}
}
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
t.Run("permission list max-results", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.args["maxResults"] != 10 {
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
}
})
t.Run("cover file-id alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
t.Fatalf("cover --file-id: %v", err)
}
})
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
t.Fatalf("revert --doc-id: %v", err)
}
})
t.Run("star add url alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
t.Fatalf("star add --url: %v", err)
}
})
t.Run("download --version routes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
out := filepath.Join(t.TempDir(), "out.pdf")
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
t.Fatalf("download --version: %v", err)
}
})
}
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "a@b.com",
"--folder-id", "cal-1",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
)
if err != nil {
t.Fatalf("calendar-event list --folder-id: %v", err)
}
}
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
t.Fatalf("identical should be nil, got %q", got)
}
old := []byte("one\ntwo\nthree\n")
neu := []byte("one\nTWO\nthree\nfour\n")
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
t.Fatalf("expected hunk diff, got %q", diff)
}
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
}
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().Int("latest", 0, "")
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
if err := validateDriveListLatest(cmd, 0); err == nil {
t.Fatal("expected latest lower bound")
}
if err := validateDriveListLatest(cmd, 51); err == nil {
t.Fatal("expected latest upper bound")
}
_ = cmd.ParseFlags([]string{"--order-by=name"})
if err := validateDriveListLatest(cmd, 3); err == nil {
t.Fatal("expected exclusive order-by")
}
cmd2 := &cobra.Command{Use: "list"}
cmd2.Flags().Int("latest", 0, "")
cmd2.Flags().String("order-by", "", "")
cmd2.Flags().String("order", "", "")
cmd2.Flags().Int("limit", 0, "")
cmd2.Flags().Int("max", 0, "")
cmd2.Flags().String("cursor", "", "")
cmd2.Flags().String("next-token", "", "")
_ = cmd2.ParseFlags([]string{"--limit=10"})
if err := validateDriveListLatest(cmd2, 3); err == nil {
t.Fatal("expected exclusive limit")
}
cmd3 := &cobra.Command{Use: "list"}
cmd3.Flags().Int("latest", 0, "")
cmd3.Flags().String("order-by", "", "")
cmd3.Flags().String("order", "", "")
cmd3.Flags().Int("limit", 0, "")
cmd3.Flags().Int("max", 0, "")
cmd3.Flags().String("cursor", "", "")
cmd3.Flags().String("next-token", "", "")
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
if err := validateDriveListLatest(cmd3, 3); err == nil {
t.Fatal("expected exclusive cursor")
}
if err := validateDriveListLatest(cmd3, 3); err == nil {
// already failed above
}
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
items := []map[string]any{
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
}
got := applyDriveListLatest(items, 1)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
stripDriveDepthDecorations(got)
if _, ok := got[0]["sortTime"]; ok {
t.Fatal("sortTime should be stripped")
}
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
t.Fatalf("float millis=%v %v", ms, ok)
}
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
}
if _, ok := toMillis(""); ok {
t.Fatal("empty string should fail")
}
if _, ok := toMillis(float64(-1)); ok {
t.Fatal("negative float should fail")
}
if ms, ok := toMillis("42"); !ok || ms != 42 {
t.Fatalf("int string=%v %v", ms, ok)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
listCmd := &cobra.Command{Use: "list"}
listCmd.SetContext(context.Background())
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
t.Fatalf("dry-run latest: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
}}}
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = oldArgs })
listCmd2 := &cobra.Command{Use: "list"}
listCmd2.SetContext(context.Background())
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
t.Fatalf("latest scan: %v", err)
}
}
func executeWhiteboardCommand(t *testing.T, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root := newDocWhiteboardCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
t.Run("dry-run plan", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
t.Fatalf("whiteboard insert dry-run: %v", err)
}
})
t.Run("insert with placement and verify", func(t *testing.T) {
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t,
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
t.Fatalf("whiteboard insert: %v", err)
}
})
t.Run("soft success when verify empty", func(t *testing.T) {
// Empty blocks are eventual-consistency pending, not hard query failure.
caller := &pr868FlexibleCaller{emptyVerify: true}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
t.Fatalf("pending-block soft success: %v", err)
}
})
t.Run("helpers", func(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
t.Cleanup(func() { os.Args = oldArgs })
if buildWhiteboardCardJSONML("b", "w") == "" {
t.Fatal("empty jsonml")
}
if extractWhiteboardID(nil) != "" {
t.Fatal("nil attrs")
}
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
t.Fatal("extract id")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected query error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing block")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected jsonml parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing attrs")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected nil attrs")
}
})
}
type pr868FlexibleCaller struct {
whiteboardID string
emptyVerify bool
format string
dry bool
}
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
if tool == "insert_document_block" {
return textToolResult(`{}`), nil
}
if tool == "list_document_blocks" {
if c.emptyVerify {
return textToolResult(`{"blocks":[]}`), nil
}
blockID, _ := args["blockId"].(string)
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
return textToolResult(payload), nil
}
return textToolResult(`{}`), nil
}
func (c *pr868FlexibleCaller) Format() string { return c.format }
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
func (*pr868FlexibleCaller) Fields() string { return "" }
func (*pr868FlexibleCaller) JQ() string { return "" }
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
t.Fatal("formatFileSize")
}
small := filepath.Join(t.TempDir(), "ok.md")
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(small); err != nil {
t.Fatalf("checkFileSize small: %v", err)
}
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected missing file")
}
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
if !changed || add < 1 || hunks < 1 || diff == "" {
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
}
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
t.Fatal("identical should be unchanged")
}
right := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
t.Fatal(err)
}
if describeDingTalkDocType("adoc") == "" {
t.Fatal("describeDingTalkDocType")
}
_ = right
}
@@ -141,23 +141,18 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
previousStdin := os.Stdin
previousPut := httpPutFile
previousGet := httpGetFile
previousWhiteboardSleep := whiteboardSleep
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
os.Stdin = previousStdin
httpPutFile = previousPut
httpGetFile = previousGet
whiteboardSleep = previousWhiteboardSleep
})
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
whiteboardSleep = func(time.Duration) {}
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {
+21
View File
@@ -49,6 +49,12 @@ var List = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +list"},
},
// Property matches CallMCP keys in Execute; type stays optional because
// the CLI default ALL satisfies the server non-empty requirement.
Parameters: []contract.ParamDecl{
{Name: "cursor", Property: "cursor"},
{Name: "type", Property: "type"},
},
},
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
@@ -95,6 +101,9 @@ var ReceiverStatus = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
},
Parameters: []contract.ParamDecl{
{Name: "ding-id", Property: "openDingId", Required: boolPtr(true)},
},
},
Flags: []shortcut.Flag{
{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
@@ -133,6 +142,13 @@ var SendPersonal = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +send-personal --users <VALUES> --content <CONTENT>"},
},
// Property keys match send_personal_ding toolArgs (not flag-name camelCase).
Parameters: []contract.ParamDecl{
{Name: "users", Property: "receiverOpenDingTalkIds", Required: boolPtr(true)},
{Name: "content", Property: "content", Required: boolPtr(true)},
{Name: "type", Property: "remindType"},
{Name: "uuid", Property: "uuid"},
},
},
Flags: []shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
@@ -204,6 +220,9 @@ var RecallPersonal = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +recall-personal --id <ID>"},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "openDingId", Required: boolPtr(true)},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
@@ -215,6 +234,8 @@ var RecallPersonal = shortcut.Shortcut{
},
}
func boolPtr(v bool) *bool { return &v }
func init() {
shortcut.Register(
List,
+1 -1
View File
@@ -154,7 +154,7 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
return f"""{PRODUCT_START}
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "{service} +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws {service} <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service {service} --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "{service} +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws {service} <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service {service} --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+62 -1
View File
@@ -730,7 +730,6 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
new string
}{
{name: "type", old: oldParameter.Type, new: newParameter.Type},
{name: "property", old: oldParameter.Property, new: newParameter.Property},
{name: "default", old: oldParameter.Default, new: newParameter.Default},
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
{name: "format", old: oldParameter.Format, new: newParameter.Format},
@@ -739,6 +738,13 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
}
}
// Property remaps are incompatible unless a reviewed correction replaces
// flag-name inference with the ParamDecl-declared MCP field. Arbitrary
// non-empty A→B remaps remain a contract break.
if oldParameter.Property != newParameter.Property &&
!compatibleReviewedPropertyCorrection(toolPath, name, oldParameter.Property, newParameter.Property) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, name))
}
// Clearing interface_type is accepted as compatible: a deliberate,
// wire-visible policy decision taken with the pinned MCP metadata
// retirement. Production no longer projects MCP-sourced types unless
@@ -783,6 +789,61 @@ func enumNarrowed(oldValues, newValues []string) bool {
return false
}
// reviewedPropertyCorrection is an exact allowlist entry for replacing a
// historical wire property (typically flag-name inference) with the MCP field
// declared on leaf ParamDecl.Property. Do not use this to bless arbitrary
// remaps: each entry must name tool path, parameter, old property, and new
// property exactly.
type reviewedPropertyCorrection struct {
toolPath string
param string
oldProperty string
newProperty string
}
// Keep this list tiny and evidence-backed. Prefer ParamDecl declaration; only
// add an entry when schema-compat would otherwise freeze a wrong inferred
// property that agents bind against. ding pilot: Execute/CallMCP keys are the
// ground truth (see internal/shortcut/ding/ding.go).
var reviewedPropertyCorrections = []reviewedPropertyCorrection{
{
toolPath: "ding/ding.shortcut_receiver_status",
param: "ding-id",
oldProperty: "dingId",
newProperty: "openDingId",
},
{
toolPath: "ding/ding.shortcut_recall_personal",
param: "id",
oldProperty: "id",
newProperty: "openDingId",
},
{
toolPath: "ding/ding.shortcut_send_personal",
param: "users",
oldProperty: "users",
newProperty: "receiverOpenDingTalkIds",
},
{
toolPath: "ding/ding.shortcut_send_personal",
param: "type",
oldProperty: "type",
newProperty: "remindType",
},
}
func compatibleReviewedPropertyCorrection(toolPath, param, oldProperty, newProperty string) bool {
for _, entry := range reviewedPropertyCorrections {
if entry.toolPath == toolPath &&
entry.param == param &&
entry.oldProperty == oldProperty &&
entry.newProperty == newProperty {
return true
}
}
return false
}
func mergeContracts(historical, current schemaContract) (schemaContract, []string) {
failures := checkCompatibility(historical, current)
if len(failures) > 0 {
+86
View File
@@ -596,6 +596,92 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
}
}
func TestReviewedDingPropertyCorrections(t *testing.T) {
if !compatibleReviewedPropertyCorrection(
"ding/ding.shortcut_send_personal", "users", "users", "receiverOpenDingTalkIds",
) {
t.Fatal("reviewed ding +send-personal --users correction must be accepted")
}
if !compatibleReviewedPropertyCorrection(
"ding/ding.shortcut_send_personal", "type", "type", "remindType",
) {
t.Fatal("reviewed ding +send-personal --type correction must be accepted")
}
if !compatibleReviewedPropertyCorrection(
"ding/ding.shortcut_receiver_status", "ding-id", "dingId", "openDingId",
) {
t.Fatal("reviewed ding +receiver-status --ding-id correction must be accepted")
}
if !compatibleReviewedPropertyCorrection(
"ding/ding.shortcut_recall_personal", "id", "id", "openDingId",
) {
t.Fatal("reviewed ding +recall-personal --id correction must be accepted")
}
if compatibleReviewedPropertyCorrection(
"ding/ding.shortcut_send_personal", "users", "users", "userIds",
) {
t.Fatal("non-reviewed new property must remain incompatible")
}
if compatibleReviewedPropertyCorrection(
"ding/ding.send_ding_message", "users", "users", "receiverUserIdList",
) {
t.Fatal("unlisted tool/param remap must remain incompatible")
}
baseline := schemaContract{
Products: map[string]productSchema{
"ding": {Tools: map[string]toolSchema{
"ding.shortcut_send_personal": {
PrimaryCLIPath: "ding +send-personal",
InterfaceMode: "composite",
Availability: "available",
Effect: "write",
Risk: "medium",
Confirmation: "user_required",
Idempotency: "unknown",
Parameters: map[string]parameterSchema{
"users": {Type: "array", Property: "users"},
"type": {Type: "string", Property: "type"},
},
},
"ding.shortcut_receiver_status": {
PrimaryCLIPath: "ding +receiver-status",
InterfaceMode: "composite",
Availability: "available",
Effect: "read",
Risk: "low",
Confirmation: "not_required",
Idempotency: "idempotent",
Parameters: map[string]parameterSchema{
"ding-id": {Type: "string", Property: "dingId", Required: true},
},
},
}},
},
}
corrected := cloneContract(baseline)
corrected.Products["ding"].Tools["ding.shortcut_send_personal"].Parameters["users"] = parameterSchema{
Type: "array", Property: "receiverOpenDingTalkIds",
}
corrected.Products["ding"].Tools["ding.shortcut_send_personal"].Parameters["type"] = parameterSchema{
Type: "string", Property: "remindType",
}
corrected.Products["ding"].Tools["ding.shortcut_receiver_status"].Parameters["ding-id"] = parameterSchema{
Type: "string", Property: "openDingId", Required: true,
}
if failures := checkCompatibility(baseline, corrected); len(failures) != 0 {
t.Fatalf("reviewed ding property corrections should pass: %v", failures)
}
broken := cloneContract(baseline)
broken.Products["ding"].Tools["ding.shortcut_send_personal"].Parameters["users"] = parameterSchema{
Type: "array", Property: "userIds",
}
if failures := checkCompatibility(baseline, broken); !strings.Contains(strings.Join(failures, "\n"), "changed property") {
t.Fatalf("unreviewed property remap should fail: %v", failures)
}
}
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
oldTool := toolSchema{
Parameters: map[string]parameterSchema{
@@ -77,6 +77,10 @@ func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
if skip || path == "" || antiCommands[path] {
continue
}
if issue := schemaProjectionIssue(ref.Text); issue != "" {
failures = append(failures, formatFailure(rootPath, ref, issue))
continue
}
if checked[path] {
continue
}
@@ -104,6 +108,55 @@ func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
return 0
}
// schemaProjectionIssue keeps published Agent instructions on the bounded
// Schema projection. A targeted full leaf is intentionally reserved for
// mapping/provenance audits; those callers must select the exact fields they
// need with --jq/--fields instead of loading the full payload into context.
func schemaProjectionIssue(raw string) string {
tokens := shellFields(raw)
if len(tokens) < 2 || tokens[0] != "dws" || tokens[1] != "schema" {
return ""
}
var targeted, compact, selected, all bool
for i := 2; i < len(tokens); i++ {
token := tokens[i]
switch {
case token == "--compact":
compact = true
case token == "--all":
all = true
case token == "--cli-path":
targeted = true
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--cli-path="):
targeted = true
case token == "--jq" || token == "--fields":
selected = true
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--jq=") || strings.HasPrefix(token, "--fields="):
selected = true
case token == "--format" || token == "-f":
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--format="):
// Output encoding does not bound the Schema fields.
case strings.HasPrefix(token, "-"):
// Other global flags do not affect projection size.
default:
targeted = true
}
}
if targeted && !all && !compact && !selected {
return "targeted Schema queries in Agent instructions must use --compact (or an explicit --jq/--fields projection)"
}
return ""
}
func extractReferences(root string) ([]commandRef, error) {
var refs []commandRef
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
@@ -169,3 +169,27 @@ func TestIsPlaceholder(t *testing.T) {
}
}
}
func TestSchemaProjectionIssue(t *testing.T) {
tests := []struct {
command string
wantErr bool
}{
{command: `dws schema`, wantErr: false},
{command: `dws schema --all --format json`, wantErr: false},
{command: `dws schema calendar`, wantErr: true},
{command: `dws schema "calendar event create" -f json`, wantErr: true},
{command: `dws schema "calendar event create" --compact -f json`, wantErr: false},
{command: `dws schema --cli-path "chat +messages-send" --compact --format json`, wantErr: false},
{command: `dws schema --cli-path="chat +messages-send" --jq '.parameters'`, wantErr: false},
{command: `dws schema "ding message send" --fields parameters`, wantErr: false},
{command: `dws doc read --node <DOC_ID>`, wantErr: false},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
if got := schemaProjectionIssue(test.command); (got != "") != test.wantErr {
t.Fatalf("schemaProjectionIssue() = %q, wantErr=%v", got, test.wantErr)
}
})
}
}
+12 -14
View File
@@ -9,7 +9,7 @@ cli_version: ">=1.0.15"
通过 `dws` 命令管理钉钉产品能力。
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;公开基础命令和内建 `+` shortcut 的 leaf Schema 决定 Agent 选择、参数映射/约束和安全确认语义。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> **命令可用性以当前 dws 二进制为准**。本文档随内置 skill 发布,可能滞后于二进制;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;公开基础命令和内建 `+` shortcut 的 leaf Schema(常规用 `--compact`)决定 Agent 选择、参数/约束和安全确认语义。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 严格禁止 (NEVER DO)
- 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器)
@@ -29,7 +29,7 @@ cli_version: ">=1.0.15"
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
- 先按产品参考、意图表和 recipe 路由。用户意图可由可见 Shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。只有脚本明确补足 Shortcut 未覆盖的复合交付物且其安全/完整性契约仍适用时才选择脚本。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --compact --format json` 读取 Agent 选择、参数、跨参数约束和 risk/confirmation;只有参数映射、接口绑定或 provenance 审计才通过 `--jq` 精确读取 full leaf;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
@@ -207,22 +207,19 @@ Step 3 → 加 --yes 执行命令
**已知命令路径例外**:当本 Skill、产品意图表或任务 reference 已经给出精确 CLI path 时,不要再查询产品级/分组级 Schema,也不要调用完整 Shortcut Catalog;可直接执行。只有参数、约束或安全语义不确定时才读取该命令的 leaf Schema,只有当前 Cobra flags 不确定时才补读 leaf Help。
稳定 command identity、主 CLI path 和 alias 已在构建时由 reviewed registry 与真实 Cobra tree 精确绑定。Agent 不应读取 Catalog 文件、native annotation 或其他生成 JSON 来重新推断命令;所有运行时查询都以当前二进制交付的 Schema 投影为准。
稳定 command identity、主 CLI path 和 alias 由 leaf `ContractFinal.Identity` 与真实 Cobra tree 精确绑定。Agent 不应读取 Catalog 文件、native annotation 或其他生成 JSON 来重新推断命令;所有运行时查询都以当前二进制交付的 Schema 投影为准。
```bash
# 第 1 层:产品概览(~4.5KB,列出全部产品 + 工具数 + 用途摘要)
dws schema
# 第 2 层:产品级(列出该产品下全部工具的 cli_path + description + effect/risk)
dws schema calendar
dws schema calendar --compact
# 第 3 层:分组级(按命令分组列出工具摘要)
dws schema "calendar event"
dws schema "calendar event" --compact
# 第 4 层:完整 leaf(参数契约:type/required/description/constraints/examples)
dws schema "calendar event create"
# --compact:当前支持;去除 provenance/debug 字段,仅保留 Agent 选参所需
# 第 4 层:Agent leaf(参数契约:type/required/description/constraints/examples)
dws schema "calendar event create" --compact
# --all:导出所有工具的完整 leaf Schema,仅用于 CI / 审计 / 参数 baseline
@@ -231,9 +228,9 @@ dws schema --all --format json
**`--all` 使用边界(强制)**:`--all` 会返回每个工具的完整参数、约束和安全语义,输出体积很大。仅在用户明确要求全量导出,或执行 CI、Catalog 审计、参数防丢 baseline 时使用。普通业务任务严禁使用 `--all` 做命令发现,也不要把全量结果直接注入 Agent 上下文;必须按“产品概览 → 产品/分组 → leaf”渐进查询。完整兼容性 baseline 必须使用未裁剪的 `schema --all`;`schema --all --compact` 会移除 provenance 和接口映射字段,不得作为完整 baseline。
同一个工具的 leaf 查询与 `--all` 条目是同一份 `ToolSpec` 契约的投影,参数、安全和接口语义必须一致。Alias 查询只改变路径视图;不得根据 alias 重写或补猜参数。若观察到内容差异,应作为契约漂移报告,而不是选择其中一份继续执行。
同一个工具省略 `--compact` 的 full leaf 与 `--all` 条目是同一份 `ToolSpec` 契约;compact leaf 只做展示投影,不重新解析语义。Alias 查询不得根据 alias 重写或补猜参数。若同一视图观察到内容差异,应作为契约漂移报告,而不是选择其中一份继续执行。
**--compact 模式**去掉的字段:`agent_metadata_source`、`agent_source_refs`、`agent_summary_source`、`effect_source`、`metadata_source`、`interface_ref`、`interface_description`、`property`、`primary_cli_path`、`parameter_count` 等 provenance/debug 字段。保留的字段:`cli_path`、`canonical_path`、`description`、`effect`、`risk`、`confirmation`、`interface_mode`、`availability`、`interface_reason`、`parameters`(含 `type`/`required`/`description`/`default`/`enum`)、`constraints`、`examples`、`use_when`、`avoid_when`。
**`--compact` Agent 模式**采用正向字段白名单。保留 `cli_path`、`canonical_path`、`description`、`effect`、`risk`、`confirmation`、`interface_mode`、`availability`、`interface_reason`、`parameters`(含 `type`/`required`/`description`/`default`/`enum`)、`constraints`、`examples`、`use_when`、`avoid_when`;新增 full/audit 字段不会自动泄漏进 Agent 上下文。它有意不返回 `interface_ref`、参数 `property/interface_type` 和 provenance;检查这些映射事实时,用 full leaf 配合 `--jq` 精确投影。
`--compact` 是 Schema 展示能力。当前版本支持;若兼容旧二进制时收到 `unknown_flag: --compact`,仅去掉 `--compact` 重跑同一个 Schema 查询。不要因此判定 leaf 不存在,也不要改用 Schema 查询业务数据。
@@ -272,7 +269,8 @@ dws schema --all --format json
| 信息 | 事实源 |
|------|--------|
| 命令是否存在、当前 Cobra 接受哪些 flags | `dws <cli_path> --help` |
| Agent 选择、参数映射/required/组合约束、risk/confirmation(原子/基础命令) | `dws schema "<cli_path>"`(按需加 `--compact`) |
| Agent 选择、CLI 参数/required/组合约束、risk/confirmation(原子/基础命令) | `dws schema "<cli_path>" --compact` |
| CLI↔RPC 参数映射、接口绑定或 provenance 审计 | full leaf 配合 `--jq` / `--fields` 精确投影;不要把整个 full leaf 注入 Agent 上下文 |
| shortcut 的参数、组合约束、risk/confirmation、示例 | 已知路径优先 `dws schema --cli-path "<service> +<shortcut>" --compact --format json`;完整 `shortcut list` 仅用于无法定位低频能力时的最后回退 |
| 人类可读用法 | `dws <cli_path> --help` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行对应的 `read` / `search` / `list` 命令 |
@@ -287,9 +285,9 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
### Helper-only 与本地 Cobra 命令
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令必须先进入 reviewed CommandRegistry,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得在查询时重新调用 MCP `tools/list`,也不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令须由 leaf `ContractFinal.Identity` 声明收集,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume" --compact` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
`source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:<server>`,也不要假定本地命令必然是 `source=cobra`。
+5 -1
View File
@@ -4,7 +4,7 @@
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和确认语义。
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --compact --format json` 读取最终参数、约束和确认语义。
| 意图 | 首选 |
|---|---|
@@ -1245,8 +1245,11 @@ Usage:
dws chat message reply [flags]
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>
# 被引用消息的 openMessageId、发送者 openDingTalkId 通过 dws chat message list 获取
Flags:
--at-all @所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)
--at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐)
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊)
--ref-msg-id string 被引用的消息 openMessageId (必填)
--ref-sender string 被引用消息的发送者 openDingTalkId (必填)
@@ -1256,6 +1259,7 @@ Flags:
注意:
- 以当前用户身份引用回复,语义同 chat message send;目前回复类型仅支持 text
- 群聊 @指定成员时,正文缺少对应 <@openDingTalkId> 会自动补齐,已有裸 @openDingTalkId 会规范化;--at-all 会自动补齐 <@all>
```
#### 转发单条消息 — 将一条消息从源会话转发到目标会话(源/目标均支持单聊/群聊)
+1 -1
View File
@@ -20,7 +20,7 @@
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 |
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 |
注意区分两个 schema:`dws event schema <event_key>` 查事件的输出字段;`dws schema "event consume"` 查 consume 命令自身的入参(统一内嵌 ToolSpec,含 parameters + 位置参数)。`source` 是 reviewed command identity 的 provenance;`event list/schema` 是 `interface_mode=local`,`event consume/status/stop` 因同时编排远端订阅控制面与本地 bus 而是 `interface_mode=composite`,不要把 identity 与实现机制混为一谈。
注意区分两个 schema:`dws event schema <event_key>` 查事件的输出字段;`dws schema "event consume" --compact` 查 consume 命令自身的入参(统一内嵌 ToolSpec,含 parameters + 位置参数)。`source` 是 reviewed command identity 的 provenance;`event list/schema` 是 `interface_mode=local`,`event consume/status/stop` 因同时编排远端订阅控制面与本地 bus 而是 `interface_mode=composite`,不要把 identity 与实现机制混为一谈。
## Event catalog
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "aitable +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws aitable <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service aitable --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "aitable +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws aitable <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service aitable --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "calendar +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws calendar <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service calendar --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "calendar +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws calendar <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service calendar --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -9,6 +9,6 @@ DWS 当前公开的是 `im.streaming-card.v1` 工作流契约,不是任意组
- callback:不支持。
参数、required 和 confirmation 读取
`dws schema --cli-path "chat +messages-send-card" -f json` 或
`dws schema --cli-path "chat +messages-update-card" -f json`。不要把 Lark card JSON 字段翻译成
`dws schema --cli-path "chat +messages-send-card" --compact -f json` 或
`dws schema --cli-path "chat +messages-update-card" --compact -f json`。不要把 Lark card JSON 字段翻译成
未发布的 DWS flags。
@@ -180,11 +180,18 @@ dws chat message edit --group <openConversationId> --msg-id <openMessageId> --co
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `message reply` | 引用回复,单聊/群聊均可 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text` |
| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` |
| `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` |
| `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` |
| `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` |
群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。
```bash
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请大家确认" --at-all
```
### 话题与卡片
话题完整读取流程:
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "contact +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws contact <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service contact --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "contact +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws contact <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service contact --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -29,7 +29,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "devapp +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws devapp <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service devapp --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "devapp +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws devapp <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service devapp --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -30,7 +30,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "doc +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws doc <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service doc --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "doc +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws doc <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service doc --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "drive +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws drive <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service drive --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "drive +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws drive <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service drive --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -94,7 +94,7 @@ kind + events + target
- 已知 Golden Route 时直接执行,不先跑 `event list`。
- 只有解析业务字段时才用 `dws event schema <event_key> --flatten`。
- 只有参数或安全不确定时才用 `dws schema --cli-path "event +listen-im"` 或对应 leaf。
- 只有参数或安全不确定时才用 `dws schema --cli-path "event +listen-im" --compact` 或对应 compact leaf。
- `event schema` 描述事件 payload;顶层 `dws schema` 描述 CLI 命令,两者不要混用。
## Reference
@@ -54,5 +54,5 @@
## Schema 边界
- 业务 payload:`dws event schema <event_key> --flatten`。
- CLI 参数/安全:`dws schema --cli-path "event +listen-im" -f json` 或精确 consume leaf。
- CLI 参数/安全:`dws schema --cli-path "event +listen-im" --compact -f json` 或精确 compact consume leaf。
- `--flatten` 的 `jq_root_path` 为 `.`;兼容 transport envelope 才使用 `.data | fromjson`。
+1 -1
View File
@@ -18,7 +18,7 @@ metadata:
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> **命令可用性以当前 dws 二进制为准**。本文档随内置 skill 发布,可能滞后于二进制;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[hrbrain.md](references/hrbrain.md)。
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "mail +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws mail <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service mail --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "mail +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws mail <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service mail --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -18,7 +18,7 @@ metadata:
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> **命令可用性以当前 dws 二进制为准**。本文档随内置 skill 发布,可能滞后于二进制;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[markdown.md](references/markdown.md)。
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "minutes +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws minutes <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service minutes --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "minutes +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws minutes <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service minutes --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -1411,7 +1411,7 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "attendance +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws attendance <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service attendance --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "attendance +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws attendance <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service attendance --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -169,7 +169,7 @@ dws ding message recall-personal --id <OPEN_DING_ID> --format json
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "ding +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws ding <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service ding --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "ding +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws ding <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service ding --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -875,7 +875,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "oa +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws oa <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service oa --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "oa +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws oa <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service oa --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -445,7 +445,7 @@ dws report outbox list --cursor 0 --size 20 --format json
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "report +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws report <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service report --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "report +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws report <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service report --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -210,7 +210,7 @@ Flags:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "sheet +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws sheet <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service sheet --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "sheet +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws sheet <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service sheet --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "todo +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws todo <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service todo --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "todo +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws todo <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service todo --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "wiki +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws wiki <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service wiki --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "wiki +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws wiki <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service wiki --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
@@ -112,7 +112,7 @@ JSON 模式下,失败写到 stderr,稳定外层结构为:
```bash
# 决定 Agent 选路、参数映射与确认语义
dws schema --cli-path "chat +messages-send" --format json
dws schema --cli-path "chat +messages-send" --compact --format json
# 核对当前二进制真实接受的 flag
dws chat +messages-send --help
+1 -1
View File
@@ -83,7 +83,7 @@ var (
// Layer A still validates their Cobra registration. Executing them here can
// trigger local setup, authentication, upgrades, or long-running consumers.
mockUnsupportedRoots = map[string]bool{
"api": true, "auth": true, "cache": true, "catalog": true,
"api": true, "auth": true, "catalog": true,
"completion": true, "config": true, "doctor": true, "event": true,
"mcp": true, "plugin": true, "profile": true, "recovery": true,
"schema": true, "skill": true, "upgrade": true, "version": true,