diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
index c19abb5e..ea9f924f 100644
--- a/.github/badges/coverage.svg
+++ b/.github/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 125c6dd2..98abb710 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ dws
test/cli/testdata/
tmp/
test/cli_compat/testdata/
+/internal/compat/testdata/*
.gitignore
.worktrees/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b580821..d345be68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
+## [1.0.15] - 2026-04-23
+
+Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws ` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
+
+### Added
+
+- **`internal/compat` subcommand merging via `attachOrMerge`** — when two or more server entries attach to the same parent (e.g. `parent: "chat"`) and their `cli.command` collides with an existing subcommand in the parent's tree, the new subcommand's children are merged recursively into the existing one instead of creating a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the "double `group` / `message` rows in `dws chat --help`" symptom when bot capabilities are distributed across `chat.group.members` and `chat.message`.
+- **`docs/command-index.md`** — a single, English, auto-generated listing of every runtime command the `dws` CLI exposes under the pre environment (159 total). Each entry carries a description and a "when to use" column aimed at AI agents. Replaces the earlier `command-index.pre.*` / `command-index.full.*` ad-hoc snapshots.
+
+### Changed
+
+- **README Key Services table** (`README.md` + `README_zh.md`) fully synced to the shipped command surface:
+ - `Chat`: 20 → **23** (bot capabilities merged in; new `list-all` / `list-focused` / `list-unread-conversations` / `conversation-info` exposed)
+ - `Calendar`: 13 → **14**
+ - `AI Tables`: 37 → **41** (chart / dashboard public-share config rows)
+ - `Doc`: 16 → **21** (comment subtree + `file create`)
+ - `Minutes`: 22 → **19** (single-tool `record`, `list query`, `list-by-keyword-range` pruned)
+ - New `Drive` row (6 commands) — promoted out of "Coming soon"
+ - `Workbench` row and standalone `Bot` row removed
+ - Total revised to **159 commands across 13 products**
+- **Quick Start** expanded to 7 examples covering `doc`, `minutes`, `drive` in addition to `contact`, `calendar`, `todo`
+- **Coming soon** trimmed to 5: `mail`, `conference`, `aiapp`, `live`, `wiki`
+- **Reference & Docs** section now leads with a pointer to the new `docs/command-index.md`
+- **Flag naming cleanup** — CLI flags across chat, calendar, drive, minutes, contact, and devdoc have been standardised so the names users type match the product-skill documentation. Notable flags:
+ - `dws contact user search` / `dws contact dept search` / `dws devdoc article search` now take `--query` (previously `--keyword`)
+ - `dws chat message list` / `dws chat message search` / `dws chat message list-mentions` / `dws chat conversation-info` / `dws chat message send` now take `--group` for the target conversation (previously `--id`) and `--open-dingtalk-id` (previously `--open-id`)
+ - `dws chat message list-by-sender` now takes `--sender-user-id` / `--sender-open-dingtalk-id` (previously `--user` / `--open-id`)
+ - `dws chat message list-topic-replies` now takes `--group` / `--topic-id` / `--limit` / `--time` (previously `--id` / `--topic` / `--size` / `--start`)
+ - `dws chat search-common` now takes `--match-mode` (previously `--mode`)
+ - `dws drive list` now takes `--max` / `--thumbnail` (previously `--max-results` / `--with-thumbnail`)
+ - `dws calendar event suggest` now takes `--users` / `--duration` / `--timezone` (previously `--attendee-user-ids` / `--duration-minutes` / `--time-zone`)
+ - `dws minutes list mine` / `dws minutes list shared` now take `--max` (previously `--max-results`) and gain `--query` / `--start` / `--end`
+ - `dws minutes list all` no longer exposes the legacy `--__scope__` internal alias
+- **Flag coverage additions** — `dws calendar event create` / `update` gain `--attendees`, `--open-dingtalk-ids`, `--timezone`; `dws chat message send` gains file-message flags (`--dentry-id`, `--file-name`, `--file-size`, `--file-type`, `--media-id`, `--msg-type`, `--space-id`) plus `--open-dingtalk-id` / `--user`; `dws chat message list` gains `--open-dingtalk-id` / `--user`; `dws aitable table delete` gains `--reason`; `dws calendar participant add` gains `--optional`; `dws todo task create` gains `--recurrence`.
+
+### Tests
+
+- 3 new unit tests in `internal/compat/dynamic_commands_test.go`:
+ - `TestBuildDynamicCommands_ParentMergeSameName` — two servers with identical `command` + `parent` collapse into a single merged subcommand
+ - `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
+ - `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
+
## [1.0.13] - 2026-04-22
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
diff --git a/README.md b/README.md
index c96a9dbc..9d24f3a7 100644
--- a/README.md
+++ b/README.md
@@ -185,12 +185,17 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
## Quick Start
```bash
-dws contact user search --keyword "engineering" # search contacts
-dws calendar event list # list calendar events
+dws contact user search --query "engineering" # search contacts
+dws calendar event list # list today's calendar events
+dws doc search --query "quarterly" # search DingTalk Docs
+dws minutes list mine # list AI meeting notes I created
+dws drive list # list DingTalk drive files
dws todo task create --title "Quarterly report" --executors "" # create a todo (replace )
dws todo task list --dry-run # preview without executing
```
+> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all 159 commands with descriptions and when-to-use guidance.
+
## Using with Agents
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
@@ -350,26 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
-| Chat / IM | `chat` (alias `im`) | 20 | `message` `group` `search` `list-top-conversations` | User-identity send (group / 1-on-1 / open-dingtalk-id), Markdown + image, @mentions; read & search conversations (list, list-all, topic replies, by-sender, mentions, focused, unread, search, info, top / common groups); group CRUD + member management |
-| Bot | `chat bot` | 7 | `bot` `group` `message` `search` `create` `search-groups` | Bot create / search, search bot groups; bot-identity group & batch-1:1 messaging, Webhook, message recall; add bot to group |
-| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
+| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
+| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
-| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
+| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
-| Ding | `ding` | 2 | `message` | Send/recall DING messages |
+| Ding | `ding` | 2 | `message` | Send / recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
-| AITable | `aitable` | 37 | `base` `table` `record` `field` `attachment` `template` `chart` `dashboard` `export` `import` `view` | Full CRUD for bases/tables/records/fields; charts/dashboards; data import/export; views; templates |
-| Doc | `doc` | 16 | `search` `list` `info` `read` `create` `update` `upload` `download` `folder` `block` `comment` | Search, read, create/update documents; block-level editing; file upload/download; comments |
-| Minutes | `minutes` | 22 | `list` `get` `update` `record` `hot-word` `mind-graph` `replace-text` `speaker` `upload` | List/search AI meeting transcripts; summaries, transcriptions, todos, mind-maps; recording control; speaker management, hot-words, file upload |
-| Workbench | `workbench` | 2 | `app` | Batch query app details |
-| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
+| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
+| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
+| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
+| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
+| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
-> 152 commands across 14 products. Run `dws --help` for the full list, or `dws --help` for subcommands.
+> **159 commands across 13 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws --help` for subcommands.
+
+> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
Coming soon
-`mail` (email) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
+`mail` (email) · `conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
@@ -418,6 +424,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Reference & Docs
+- [Command Index](./docs/command-index.md) — every runtime command (159 total) with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Changelog](./CHANGELOG.md) — release history and migration notes
diff --git a/README_zh.md b/README_zh.md
index ed8ce87c..81facb79 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -185,12 +185,17 @@ dws auth login --client-id --client-secret
## 快速开始
```bash
-dws contact user search --keyword "悟空" # 搜索联系人
-dws calendar event list # 查看日历日程
+dws contact user search --query "悟空" # 搜索联系人
+dws calendar event list # 查看今天的日程
+dws doc search --query "季度" # 搜索钉钉文档
+dws minutes list mine # 列出我创建的 AI 听记
+dws drive list # 列出钉盘文件
dws todo task create --title "季度汇报" --executors "" # 创建待办(请替换为真实 userId)
dws todo task list --dry-run # 预览操作但不执行
```
+> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部 159 条命令,带描述和使用场景。
+
## 在 Agent 中使用
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
@@ -350,24 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
-| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
-| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
-| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
+| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
+| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
-| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
+| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
-| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
-| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
-| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
-| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
-| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
+| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
+| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
+| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
+| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
+| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
+| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
+| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
-> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。
+> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws --help` 查看子命令。
+
+> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
即将推出
-`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
+`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
@@ -418,6 +426,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 参考与文档
+- [命令索引](./docs/command-index.md) — 159 条运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
diff --git a/docs/command-index.md b/docs/command-index.md
new file mode 100644
index 00000000..dfd964fc
--- /dev/null
+++ b/docs/command-index.md
@@ -0,0 +1,323 @@
+# dws Command Index
+
+Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
+
+- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
+- **Products**: 13
+- **Total commands**: 159
+- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
+
+> Auto-generated. Edit `pre/config.json`, not this file.
+
+## Global flags
+
+Every command inherits these flags (documented here once, not repeated per command):
+
+| Flag | Purpose |
+|---|---|
+| `--client-id` | Override OAuth client ID (DingTalk AppKey) |
+| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) |
+| `--debug` | Enable debug logging |
+| `--dry-run` | Preview the request without executing |
+| `--fields` | Comma-separated output field projection |
+| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) |
+| `--jq` | jq expression applied to JSON output |
+| `--mock` | Return mock data (developer aid) |
+| `-o, --output` | Write output to a file |
+| `--timeout` | HTTP request timeout in seconds (default 30) |
+| `--token` | Override the configured API token |
+| `-v, --verbose` | Verbose logging |
+| `-y, --yes` | Skip confirmation prompts (AI-agent mode) |
+
+## Contents
+
+- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
+- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
+- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
+- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
+- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
+- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
+- [`dws ding` — DING Messages](#dws-ding) · 2 commands
+- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
+- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
+- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
+- [`dws oa` — OA Approval](#dws-oa) · 9 commands
+- [`dws report` — Reports](#dws-report) · 7 commands
+- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
+
+## `dws aitable` — AI Tables
+
+_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._
+
+**41 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. |
+| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. |
+| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. |
+| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. |
+| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. |
+| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. |
+| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. |
+| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. |
+| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. |
+| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. |
+| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. |
+| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. |
+| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. |
+| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. |
+| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. |
+| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. |
+| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. |
+| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. |
+| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. |
+| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. |
+| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. |
+| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. |
+| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. |
+| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. |
+| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. |
+| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. |
+| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. |
+| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. |
+| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. |
+| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. |
+| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. |
+| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. |
+| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. |
+| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. |
+| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. |
+| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. |
+| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. |
+| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. |
+| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. |
+| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. |
+| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. |
+
+## `dws attendance` — Attendance
+
+_Attendance check-in records, shifts, and aggregate statistics._
+
+**4 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. |
+| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. |
+| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. |
+| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. |
+
+## `dws calendar` — Calendar
+
+_Calendar events, participants, meeting rooms, and busy-status queries._
+
+**14 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. |
+| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. |
+| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. |
+| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. |
+| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. |
+| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. |
+| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. |
+| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. |
+| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. |
+| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. |
+| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. |
+| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. |
+| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. |
+| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. |
+
+## `dws chat` — Group Chat / IM
+
+_Group chats, conversations, messages, and robot/webhook integrations._
+
+**23 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
+| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
+| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
+| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
+| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. |
+| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
+| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
+| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
+| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
+| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
+| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
+| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
+| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
+| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. |
+| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. |
+| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. |
+| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. |
+| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. |
+| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. |
+| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. |
+| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. |
+| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. |
+
+## `dws contact` — Contact Directory
+
+_Users, departments, and directory lookups._
+
+**6 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
+| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
+| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
+| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
+| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
+| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
+
+## `dws devdoc` — Open Platform Docs
+
+_Search the DingTalk Open Platform documentation._
+
+**1 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
+
+## `dws ding` — DING Messages
+
+_Send and recall DING messages (priority notifications)._
+
+**2 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. |
+| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. |
+
+## `dws doc` — DingTalk Doc
+
+_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._
+
+**21 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. |
+| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. |
+| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. |
+| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. |
+| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. |
+| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. |
+| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. |
+| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. |
+| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. |
+| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. |
+| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. |
+| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. |
+| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. |
+| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. |
+| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. |
+| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. |
+| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. |
+| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. |
+| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. |
+| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. |
+| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. |
+
+## `dws drive` — DingTalk Drive
+
+_DingTalk Drive file and folder management._
+
+**6 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. |
+| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. |
+| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. |
+| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. |
+| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. |
+| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. |
+
+## `dws minutes` — AI Minutes
+
+_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._
+
+**19 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. |
+| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. |
+| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. |
+| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. |
+| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. |
+| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. |
+| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. |
+| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. |
+| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. |
+| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. |
+| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. |
+| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. |
+| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. |
+| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. |
+| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. |
+| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. |
+| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. |
+| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. |
+| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. |
+
+## `dws oa` — OA Approval
+
+_OA approval workflows: list, approve, reject, revoke, records._
+
+**9 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
+| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
+| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
+| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
+| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. |
+| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. |
+| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. |
+| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. |
+
+## `dws report` — Reports
+
+_DingTalk Report feature: templates, entries, and statistics._
+
+**7 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. |
+| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. |
+| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). |
+| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. |
+| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. |
+| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. |
+| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. |
+
+## `dws todo` — Todo Tasks
+
+_Personal todo task management._
+
+**6 commands**
+
+| Command | Description | When to use |
+|---|---|---|
+| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. |
+| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. |
+| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. |
+| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
+| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
+| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
+
diff --git a/docs/reference.md b/docs/reference.md
index 87bb9fe8..2c37b8e0 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -32,6 +32,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
+dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
```
## Dry Run / 试运行
@@ -46,6 +47,46 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
dws contact user search --keyword "Alice" -o result.json
```
+## Schema Introspection / Schema 查询
+
+`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
+
+### 路径写法
+
+```bash
+dws schema # 列出所有产品 + 工具名
+dws schema ding.send_ding_message # canonical: product.rpc_name
+dws schema ding.message.send # CLI 点路径: product.group.cli_name
+dws schema "ding message send" # CLI 空格路径(同上)
+dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
+dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
+```
+
+Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
+
+### 单工具输出字段
+
+| 字段 | 说明 |
+|------|------|
+| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
+| `group` | CLI 父级 group 路径(dot-separated) |
+| `title` / `description` | 工具名/说明(overlay 优先) |
+| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
+| `output_schema` | MCP 输出 Schema(上游下发时才有) |
+| `sensitive` | 敏感写操作,需 `--yes` 确认 |
+| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
+| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
+
+**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
+
+### 筛选输出
+
+```bash
+dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
+dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
+dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
+```
+
## Shell Completion / 自动补全
```bash
diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go
index cdf118c8..895c919c 100644
--- a/internal/app/auth_command_test.go
+++ b/internal/app/auth_command_test.go
@@ -26,7 +26,9 @@ import (
)
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
- // Cleanup keychain after test
+ // Isolate keychain storage to a per-test directory so the saved
+ // token can't leak into other test packages running in parallel.
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
diff --git a/internal/app/auth_refresh_retry.go b/internal/app/auth_refresh_retry.go
new file mode 100644
index 00000000..d9462f9a
--- /dev/null
+++ b/internal/app/auth_refresh_retry.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "context"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
+)
+
+// authRetryingKey marks a context that has already attempted one
+// AuthRefreshRequired-driven retry of the current invocation. The runner uses
+// this to refuse a second refresh+retry pass and surface the original cause
+// to the user instead.
+type authRetryingKeyType struct{}
+
+var authRetryingKey = authRetryingKeyType{}
+
+// IsAuthRetrying reports whether the current context is already inside an
+// AuthRefreshRequired retry. Mirrors IsPatRetrying.
+func IsAuthRetrying(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ v, _ := ctx.Value(authRetryingKey).(bool)
+ return v
+}
+
+// withAuthRetrying returns a child context flagged as "already retried once"
+// so the runner does not enter an infinite refresh loop if the second attempt
+// also returns AuthRefreshRequired.
+func withAuthRetrying(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, authRetryingKey, true)
+}
+
+// handleAuthRefreshRequired performs a one-shot ForceRefresh using the active
+// configDir and re-runs the invocation through the supplied runner. It must
+// only be called when the runner has observed an *authretry.AuthRefreshRequired
+// from an edition hook (ClassifyToolResult / OnAuthError).
+//
+// Behaviour rules — all three matter for safety:
+// 1. If the context is already flagged via IsAuthRetrying, this returns
+// refresh.Cause unchanged. No further refresh attempts, no recursion.
+// 2. If ForceRefresh fails (e.g. refresh_token also expired), this returns
+// refresh.Cause so the user sees the original auth diagnostic, not an
+// internal "force refresh failed" message.
+// 3. On successful refresh, this resets the per-process token cache and
+// re-runs the invocation with withAuthRetrying applied so a second
+// refresh request from the overlay degrades gracefully to "show the
+// original error".
+func handleAuthRefreshRequired(
+ ctx context.Context,
+ r executor.Runner,
+ invocation executor.Invocation,
+ refresh *authretry.AuthRefreshRequired,
+) (executor.Result, error) {
+ if refresh == nil {
+ return executor.Result{}, nil
+ }
+ if IsAuthRetrying(ctx) {
+ return executor.Result{}, refresh.Cause
+ }
+ if _, err := ForceRefreshAccessToken(ctx, defaultConfigDir()); err != nil {
+ return executor.Result{}, refresh.Cause
+ }
+ ResetRuntimeTokenCache()
+ return r.Run(withAuthRetrying(ctx), invocation)
+}
diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go
index 33c28ac5..b95a772b 100644
--- a/internal/app/direct_runtime.go
+++ b/internal/app/direct_runtime.go
@@ -80,11 +80,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
toolEndpoints[toolName] = endpoint
}
}
- for toolName := range server.CLI.ToolOverrides {
+ for toolName, override := range server.CLI.ToolOverrides {
toolName = strings.TrimSpace(toolName)
- if toolName != "" {
- toolEndpoints[toolName] = endpoint
+ if toolName == "" {
+ continue
}
+ // Leaves with serverOverride are routed to a different server's
+ // endpoint (e.g. chat's "search_my_robots" → bot). Registering
+ // them here would overwrite the real owner's tool → endpoint
+ // mapping and send the invocation to the wrong MCP URL.
+ if strings.TrimSpace(override.ServerOverride) != "" {
+ continue
+ }
+ toolEndpoints[toolName] = endpoint
}
}
}
@@ -208,11 +216,17 @@ func AppendDynamicServer(server market.ServerDescriptor) {
dynamicToolEndpoints[toolName] = endpoint
}
}
- for toolName := range server.CLI.ToolOverrides {
+ for toolName, override := range server.CLI.ToolOverrides {
toolName = strings.TrimSpace(toolName)
- if toolName != "" {
- dynamicToolEndpoints[toolName] = endpoint
+ if toolName == "" {
+ continue
}
+ // Leaves with serverOverride are routed to a different server's
+ // endpoint; skip to avoid overwriting the real owner's mapping.
+ if strings.TrimSpace(override.ServerOverride) != "" {
+ continue
+ }
+ dynamicToolEndpoints[toolName] = endpoint
}
}
}
diff --git a/internal/app/direct_runtime_override_test.go b/internal/app/direct_runtime_override_test.go
new file mode 100644
index 00000000..6d3c940a
--- /dev/null
+++ b/internal/app/direct_runtime_override_test.go
@@ -0,0 +1,183 @@
+// 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 (
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
+)
+
+// Regression for the chat/bot tool routing bug: when the `chat` envelope
+// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
+// `send_message_by_custom_robot`), those tool names must NOT be registered
+// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
+// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
+// even when the invocation's CanonicalProduct is "bot", causing the Portal to
+// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
+// tool.
+//
+// Owner (bot envelope) still registers the tool (no serverOverride on the bot
+// side), so product-level and tool-level lookups both resolve correctly.
+
+const (
+ testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
+ testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
+)
+
+// botDescriptor returns a minimal `bot` server descriptor that owns the
+// `search_my_robots` + `send_message_by_custom_robot` tools (no
+// serverOverride — bot is the real owner).
+func botDescriptor() market.ServerDescriptor {
+ return market.ServerDescriptor{
+ Endpoint: testBotEndpoint,
+ CLI: market.CLIOverlay{
+ ID: "bot",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_my_robots": {CLIName: "search"},
+ "send_message_by_custom_robot": {CLIName: "send-by-webhook"},
+ "add_robot_to_group": {CLIName: "add-bot"},
+ },
+ },
+ }
+}
+
+// chatDescriptor returns a minimal `chat` server descriptor whose
+// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
+// chat-native tool (`search_groups_by_keyword`) that must remain routed to
+// chat's endpoint.
+func chatDescriptor() market.ServerDescriptor {
+ return market.ServerDescriptor{
+ Endpoint: testChatEndpoint,
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_groups_by_keyword": {CLIName: "search"},
+ "search_my_robots": {
+ CLIName: "search",
+ ServerOverride: "bot",
+ },
+ "send_message_by_custom_robot": {
+ CLIName: "send-by-webhook",
+ ServerOverride: "bot",
+ },
+ "add_robot_to_group": {
+ CLIName: "add-bot",
+ ServerOverride: "bot",
+ },
+ },
+ },
+ }
+}
+
+// withCleanDynamicRegistry snapshots and restores the package-level dynamic
+// registries so parallel/other tests aren't affected by this case's mutations.
+func withCleanDynamicRegistry(t *testing.T) {
+ t.Helper()
+ dynamicMu.Lock()
+ prev := struct {
+ endpoints map[string]string
+ products map[string]bool
+ aliases map[string]string
+ toolEndpoints map[string]string
+ }{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
+ dynamicEndpoints = nil
+ dynamicProducts = nil
+ dynamicAliases = nil
+ dynamicToolEndpoints = nil
+ dynamicMu.Unlock()
+ t.Cleanup(func() {
+ dynamicMu.Lock()
+ dynamicEndpoints = prev.endpoints
+ dynamicProducts = prev.products
+ dynamicAliases = prev.aliases
+ dynamicToolEndpoints = prev.toolEndpoints
+ dynamicMu.Unlock()
+ })
+}
+
+func assertEndpoint(t *testing.T, productID, toolName, want string) {
+ t.Helper()
+ got, ok := directRuntimeEndpoint(productID, toolName)
+ if !ok {
+ t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
+ }
+ if got != want {
+ t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
+ }
+}
+
+// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
+// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
+// of registration order.
+func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
+ tests := []struct {
+ name string
+ servers []market.ServerDescriptor
+ }{
+ {
+ name: "bot first, chat second",
+ servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
+ },
+ {
+ name: "chat first, bot second",
+ servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ withCleanDynamicRegistry(t)
+ SetDynamicServers(tc.servers)
+
+ // Bot-owned tools must route to bot's endpoint even though chat
+ // declares toolOverrides for them (with serverOverride="bot").
+ assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
+ assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
+ assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
+
+ // Chat-native tools must still route to chat.
+ assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
+
+ // Product-level fallback for bot (no tool name) must also return
+ // bot's endpoint.
+ assertEndpoint(t, "bot", "", testBotEndpoint)
+ })
+ }
+}
+
+// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
+// the plugin-injection path (`AppendDynamicServer`) which has the same
+// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
+// serverOverride entries must not overwrite bot's tool → endpoint mapping.
+func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
+ orders := [][]market.ServerDescriptor{
+ {botDescriptor(), chatDescriptor()},
+ {chatDescriptor(), botDescriptor()},
+ }
+
+ for _, servers := range orders {
+ t.Run("", func(t *testing.T) {
+ withCleanDynamicRegistry(t)
+ for _, s := range servers {
+ AppendDynamicServer(s)
+ }
+
+ assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
+ assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
+ assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
+ })
+ }
+}
diff --git a/internal/app/flags.go b/internal/app/flags.go
index c9a15164..df3de9f4 100644
--- a/internal/app/flags.go
+++ b/internal/app/flags.go
@@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
- cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw")
+ cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
diff --git a/internal/app/force_refresh.go b/internal/app/force_refresh.go
new file mode 100644
index 00000000..a7d9010a
--- /dev/null
+++ b/internal/app/force_refresh.go
@@ -0,0 +1,62 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
+)
+
+// ForceRefreshAccessToken forces a single refresh_token exchange and returns
+// the new access_token. It is intended for callers that have observed a
+// server-side rejection (HTTP 401 or business code such as
+// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
+//
+// Steps:
+// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
+// OAuthProvider.GetAccessToken's fast-path will miss.
+// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
+// existing dual-layer lock (process + file) to serialize concurrent
+// refresh attempts across goroutines and processes.
+// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
+// next resolveAuthToken call re-reads from disk.
+//
+// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
+// is the only entry point that orchestrates "force refresh" semantics.
+func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
+ if strings.TrimSpace(configDir) == "" {
+ return "", fmt.Errorf("config directory is empty")
+ }
+ if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
+ return "", fmt.Errorf("mark access token stale: %w", err)
+ }
+ disc := slog.New(slog.NewTextHandler(io.Discard, nil))
+ provider := authpkg.NewOAuthProvider(configDir, disc)
+ configureOAuthProviderCompatibility(provider, configDir)
+ tok, err := provider.GetAccessToken(ctx)
+ if err != nil {
+ return "", err
+ }
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ return "", fmt.Errorf("force refresh returned empty access token")
+ }
+ ResetRuntimeTokenCache()
+ return tok, nil
+}
diff --git a/internal/app/legacy.go b/internal/app/legacy.go
index e9b390e1..ab431612 100644
--- a/internal/app/legacy.go
+++ b/internal/app/legacy.go
@@ -47,12 +47,42 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
- var commands []*cobra.Command
- if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
- commands = append(commands, dynamicCmds...)
+ dynamicCmds := loadDynamicCommands(ctx, runner)
+ helperCmds := helpers.NewPublicCommands(runner)
+ return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
+}
+
+// pickCommands returns the union of dynamic and helpers commands, with
+// same-named helpers dropped so discovery envelopes remain the authority.
+//
+// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
+// on same-named top-level commands, which — at leaf conflicts — falls back to
+// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
+// typically expose more flags than the corresponding dynamic overlay leaves,
+// so a naive append would silently promote helper leaves over their dynamic
+// counterparts and append helper-only siblings into the dynamic subtree.
+// By shadowing same-named helpers upfront we keep the dynamic subtree intact
+// and let helpers only fill in products the discovery envelope did not cover.
+func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
+ dynNames := make(map[string]bool, len(dynamic))
+ out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
+ for _, c := range dynamic {
+ if c == nil {
+ continue
+ }
+ dynNames[c.Name()] = true
+ out = append(out, c)
}
- commands = append(commands, helpers.NewPublicCommands(runner)...)
- return mergeTopLevelCommands(commands)
+ for _, h := range helpers {
+ if h == nil {
+ continue
+ }
+ if dynNames[h.Name()] {
+ continue
+ }
+ out = append(out, h)
+ }
+ return out
}
// injectStaticServers converts edition.ServerInfo entries into
@@ -87,9 +117,58 @@ func injectStaticServers(servers []edition.ServerInfo) {
//
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
+// editionPartition returns the cache partition for the active edition.
+// Each edition gets its own partition to prevent cross-edition data leakage.
+func editionPartition() string {
+ name := edition.Get().Name
+ if name == "" || name == "open" {
+ return config.DefaultPartition
+ }
+ return name + "/default"
+}
+
+// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
+// loadDynamicCommands runs while building the command tree, before PersistentPreRun
+// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
+func discoveryTraceEnabled() bool {
+ if IsPerfDebugEnabled() {
+ return true
+ }
+ for _, a := range os.Args[1:] {
+ if a == "--debug" {
+ return true
+ }
+ }
+ return false
+}
+
+func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
+ seen := make(map[string]struct{})
+ for _, s := range servers {
+ id := strings.TrimSpace(s.CLI.Command)
+ if id == "" {
+ id = strings.TrimSpace(s.CLI.ID)
+ }
+ if id == "" {
+ continue
+ }
+ seen[id] = struct{}{}
+ }
+ out := make([]string, 0, len(seen))
+ for id := range seen {
+ out = append(out, id)
+ }
+ sort.Strings(out)
+ const maxIDs = 48
+ if len(out) > maxIDs {
+ out = out[:maxIDs]
+ }
+ return out
+}
+
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
store := cacheStoreFromEnv()
- partition := config.DefaultPartition
+ partition := editionPartition()
// Bypass the registry cache when a fixture override is active.
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
@@ -116,27 +195,55 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
}
+ if len(servers) > 0 && discoveryTraceEnabled() {
+ slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
+ "partition", partition,
+ "servers", len(servers),
+ "registry_freshness", string(freshness))
+ }
+
// Cache miss or bypassed: fetch from market API synchronously (first run only).
if len(servers) == 0 {
- baseURL := cli.DefaultMarketBaseURL
- if discoveryBaseURLOverride != "" {
- baseURL = discoveryBaseURLOverride
+ if discoveryTraceEnabled() {
+ if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
+ slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
+ } else {
+ baseURL := cli.DefaultMarketBaseURL
+ if discoveryBaseURLOverride != "" {
+ baseURL = discoveryBaseURLOverride
+ }
+ slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
+ }
}
fetchStart := time.Now()
- client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
- resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
+
+ resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
+
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
if fetchErr != nil {
+ if discoveryTraceEnabled() {
+ slog.Info("loadDynamicCommands: sync discovery fetch failed",
+ "partition", partition,
+ "error", fetchErr.Error())
+ }
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
servers = snapshot.Servers
} else {
- return nil
+ // no-op: fall through to FallbackServers check below
}
} else {
servers = market.NormalizeServers(resp, "market")
+ if discoveryTraceEnabled() {
+ slog.Info("loadDynamicCommands: sync discovery fetch ok",
+ "partition", partition,
+ "response_servers", len(resp.Servers),
+ "metadata_count", resp.Metadata.Count,
+ "normalized_servers", len(servers),
+ "cli_command_ids", discoveryTraceServerIDs(servers))
+ }
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
@@ -148,9 +255,22 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
}
+ // FallbackServers: safety net when Market discovery + cache both fail.
if len(servers) == 0 {
+ if fn := edition.Get().FallbackServers; fn != nil {
+ if fb := fn(); len(fb) > 0 {
+ slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
+ descriptors := fallbackToDescriptors(fb)
+ descriptors = mergeSupplementServers(descriptors)
+ SetDynamicServers(descriptors)
+ return nil
+ }
+ }
return nil
}
+
+ // Merge edition-specific supplement servers (not in Market).
+ servers = mergeSupplementServers(servers)
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
@@ -172,7 +292,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor
if store == nil {
return result
}
- partition := config.DefaultPartition
+ partition := editionPartition()
for _, server := range servers {
if server.DetailLocator.MCPID <= 0 {
continue
@@ -203,7 +323,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers
if ctx == nil {
ctx = context.Background()
}
- partition := config.DefaultPartition
+ partition := editionPartition()
now := time.Now().UTC()
if store != nil && store.Now != nil {
now = store.Now().UTC()
@@ -333,13 +453,13 @@ func DiscoveryBaseURL() string {
return cli.DefaultMarketBaseURL
}
-// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections
-// and uses a short timeout suitable for CLI startup network requests.
-// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking.
-func ipv4OnlyHTTPClient() *http.Client {
+// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
+// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
+// hosts without IPv6 networking.
+func ipv4HTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 3 * time.Second}
return &http.Client{
- Timeout: 5 * time.Second,
+ Timeout: timeout,
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
@@ -348,6 +468,29 @@ func ipv4OnlyHTTPClient() *http.Client {
}
}
+// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
+// suitable for CLI startup network requests.
+func ipv4OnlyHTTPClient() *http.Client {
+ return ipv4HTTPClient(5 * time.Second)
+}
+
+// fetchRegistryServers performs the server-list HTTP fetch honoring the
+// active edition's DiscoveryURL override. It is the single source of truth
+// for all server-list fetches (startup, async revalidation, explicit
+// `cache refresh`); keeping the edition-URL branch in one place prevents
+// call sites from drifting out of sync.
+func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
+ if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
+ client := market.NewClient("", httpClient)
+ if fn := edition.Get().DiscoveryHeaders; fn != nil {
+ client.Headers = fn()
+ }
+ return client.FetchServersFromURL(ctx, editionURL)
+ }
+ client := market.NewClient(DiscoveryBaseURL(), httpClient)
+ return client.FetchServers(ctx, config.DefaultFetchServersLimit)
+}
+
// asyncRevalidateRegistry refreshes the registry cache in the background.
// Uses a short timeout derived from the parent context and silently ignores
// errors — the next CLI invocation will pick up the refreshed cache or retry.
@@ -355,9 +498,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
- baseURL := DiscoveryBaseURL()
- client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
- resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit)
+ resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
if err != nil {
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
return
@@ -398,3 +539,50 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
})
return out
}
+
+// mergeSupplementServers appends edition-specific servers (not in Market)
+// into the discovery result. Existing IDs from Market/cache take precedence.
+func mergeSupplementServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
+ fn := edition.Get().SupplementServers
+ if fn == nil {
+ return servers
+ }
+ existing := make(map[string]bool, len(servers))
+ for _, s := range servers {
+ existing[s.CLI.ID] = true
+ existing[s.Key] = true
+ }
+ for _, sup := range fn() {
+ if !existing[sup.ID] {
+ servers = append(servers, market.ServerDescriptor{
+ Key: sup.ID,
+ DisplayName: sup.Name,
+ Endpoint: sup.Endpoint,
+ CLI: market.CLIOverlay{
+ ID: sup.ID,
+ Command: sup.ID,
+ Prefixes: sup.Prefixes,
+ },
+ })
+ }
+ }
+ return servers
+}
+
+// fallbackToDescriptors converts edition.ServerInfo into market.ServerDescriptor.
+func fallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
+ descriptors := make([]market.ServerDescriptor, 0, len(servers))
+ for _, s := range servers {
+ descriptors = append(descriptors, market.ServerDescriptor{
+ Key: s.ID,
+ DisplayName: s.Name,
+ Endpoint: s.Endpoint,
+ CLI: market.CLIOverlay{
+ ID: s.ID,
+ Command: s.ID,
+ Prefixes: s.Prefixes,
+ },
+ })
+ }
+ return descriptors
+}
diff --git a/internal/app/legacy_test.go b/internal/app/legacy_test.go
new file mode 100644
index 00000000..83bdc259
--- /dev/null
+++ b/internal/app/legacy_test.go
@@ -0,0 +1,111 @@
+// 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 (
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// TestPickCommands_DynamicShadowsSameNamedHelpers verifies that when the
+// discovery envelope produces a dynamic command with the same top-level name
+// as a helpers-registered hardcoded command, the helper is dropped and the
+// dynamic command is kept verbatim. This prevents mergeTopLevelCommands from
+// mixing helper leaves into the dynamic subtree via MergeCommandTree's
+// LocalFlagCount-based arbitration.
+func TestPickCommands_DynamicShadowsSameNamedHelpers(t *testing.T) {
+ dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
+ dyn.AddCommand(&cobra.Command{Use: "task"})
+ dynamic := []*cobra.Command{dyn}
+
+ hlp := &cobra.Command{Use: "todo", Short: "helper"}
+ hlp.AddCommand(&cobra.Command{Use: "task"})
+ helpers := []*cobra.Command{hlp}
+
+ got := pickCommands(dynamic, helpers)
+
+ if len(got) != 1 {
+ t.Fatalf("pickCommands returned %d commands, want 1", len(got))
+ }
+ if got[0] != dyn {
+ t.Fatalf("pickCommands returned helpers command, want dynamic")
+ }
+}
+
+// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
+// names are NOT in the dynamic set are preserved — the dynamic overlay only
+// shadows products it actually covers.
+func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
+ dyn := &cobra.Command{Use: "todo"}
+ dynamic := []*cobra.Command{dyn}
+
+ todoHelper := &cobra.Command{Use: "todo"}
+ attendanceHelper := &cobra.Command{Use: "attendance"}
+ chatHelper := &cobra.Command{Use: "chat"}
+ helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
+
+ got := pickCommands(dynamic, helpers)
+
+ names := make(map[string]*cobra.Command, len(got))
+ for _, c := range got {
+ names[c.Name()] = c
+ }
+ if names["todo"] != dyn {
+ t.Fatalf("todo = %v, want dynamic", names["todo"])
+ }
+ if names["attendance"] != attendanceHelper {
+ t.Fatalf("attendance not preserved from helpers")
+ }
+ if names["chat"] != chatHelper {
+ t.Fatalf("chat not preserved from helpers")
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
+ }
+}
+
+// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
+// when discovery returns nothing, helpers are the sole source of truth — the
+// behaviour must be identical to the pre-refactor append-all code path.
+func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
+ todoHelper := &cobra.Command{Use: "todo"}
+ chatHelper := &cobra.Command{Use: "chat"}
+ helpers := []*cobra.Command{todoHelper, chatHelper}
+
+ got := pickCommands(nil, helpers)
+
+ if len(got) != 2 {
+ t.Fatalf("got %d commands, want 2", len(got))
+ }
+ if got[0] != todoHelper || got[1] != chatHelper {
+ t.Fatalf("pickCommands changed helpers order or identity")
+ }
+}
+
+// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
+// a misbehaving factory.
+func TestPickCommands_NilsAreSkipped(t *testing.T) {
+ dyn := &cobra.Command{Use: "todo"}
+ hlp := &cobra.Command{Use: "chat"}
+
+ got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
+
+ if len(got) != 2 {
+ t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
+ }
+ if got[0] != dyn || got[1] != hlp {
+ t.Fatalf("unexpected ordering or identity after nil filter")
+ }
+}
diff --git a/internal/app/root.go b/internal/app/root.go
index 956f113f..4d1e6355 100644
--- a/internal/app/root.go
+++ b/internal/app/root.go
@@ -421,20 +421,25 @@ func newCacheCommand() *cobra.Command {
if err != nil {
return apperrors.NewInternal("failed to read cache refresh flags")
}
- baseURL := DiscoveryBaseURL()
store := cacheStoreFromEnv()
transportClient := transport.NewClient(nil)
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
+ // Market client here is only a fallback for Detail API calls inside
+ // DiscoverAllRuntime; the primary server-list fetch below goes
+ // through fetchRegistryServers so edition DiscoveryURL wins.
service := discovery.NewService(
- market.NewClient(baseURL, nil),
+ market.NewClient(DiscoveryBaseURL(), nil),
transportClient,
store,
)
- servers, err := service.DiscoverServers(cmd.Context())
+
+ resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
if err != nil {
- return err
+ return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
}
+ servers := market.NormalizeServers(resp, "live_market")
+ _ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
selected := selectServersForProduct(servers, product)
if strings.TrimSpace(product) != "" && len(selected) == 0 {
diff --git a/internal/app/root_cache_refresh_test.go b/internal/app/root_cache_refresh_test.go
index eed0e548..ed4d2026 100644
--- a/internal/app/root_cache_refresh_test.go
+++ b/internal/app/root_cache_refresh_test.go
@@ -12,6 +12,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
@@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
}
}
+// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
+// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
+// DiscoveryHeaders when they are set, instead of the default Market endpoint.
+// Kept deliberately generic (no edition-specific strings) — concrete values
+// belong to the overlay repo that installs the hooks, not to this open core.
+func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
+ cacheDir := t.TempDir()
+ t.Setenv(cli.CacheDirEnv, cacheDir)
+
+ var (
+ editionHits atomic.Int32
+ marketHits atomic.Int32
+ gotHeaders atomic.Value // map[string]string
+ )
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/cli/edition/apis":
+ editionHits.Add(1)
+ snapshot := map[string]string{
+ "x-test-edition": r.Header.Get("x-test-edition"),
+ "x-test-client": r.Header.Get("x-test-client"),
+ }
+ gotHeaders.Store(snapshot)
+ _ = json.NewEncoder(w).Encode(market.ListResponse{
+ Metadata: market.ListMetadata{Count: 1},
+ Servers: []market.ServerEnvelope{
+ {
+ Server: market.RegistryServer{
+ Name: "Edition Service",
+ Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
+ },
+ Meta: market.EnvelopeMeta{
+ Registry: market.RegistryMetadata{Status: "active"},
+ CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
+ },
+ },
+ },
+ })
+ case "/cli/discovery/apis":
+ marketHits.Add(1)
+ http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ edition.Override(&edition.Hooks{
+ Name: "testing",
+ DiscoveryURL: srv.URL + "/cli/edition/apis",
+ DiscoveryHeaders: func() map[string]string {
+ return map[string]string{
+ "x-test-edition": "custom",
+ "x-test-client": "cli-refresh",
+ }
+ },
+ })
+ t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
+
+ SetDiscoveryBaseURL(srv.URL)
+ t.Cleanup(func() { SetDiscoveryBaseURL("") })
+
+ cmd := newCacheCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"refresh"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if got := editionHits.Load(); got != 1 {
+ t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
+ }
+ if got := marketHits.Load(); got != 0 {
+ t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
+ }
+
+ headers, _ := gotHeaders.Load().(map[string]string)
+ if headers == nil {
+ t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
+ }
+ if headers["x-test-edition"] != "custom" {
+ t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
+ }
+ if headers["x-test-client"] != "cli-refresh" {
+ t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
+ }
+}
+
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
t.Helper()
diff --git a/internal/app/runner.go b/internal/app/runner.go
index e17227d1..89cd852a 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -168,10 +168,10 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
if !ok || strings.TrimSpace(product.Endpoint) == "" {
- return r.fallback.Run(ctx, invocation)
+ return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
}
if _, ok := product.FindTool(invocation.Tool); !ok {
- return r.fallback.Run(ctx, invocation)
+ return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
}
if r.globalFlags != nil && r.globalFlags.DryRun {
invocation.DryRun = true
@@ -184,6 +184,37 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.executeInvocation(ctx, endpoint, invocation)
}
+// handleCatalogMiss decides what to do when discovery catalog does not cover the
+// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
+//
+// Previously every catalog miss silently fell through to EchoRunner, which
+// returns an empty `executor.Result{Response: nil}`. The helper-invocation
+// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
+// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
+// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
+//
+// New contract:
+// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
+// `--dry-run` still prints the planned payload without real execution.
+// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
+// with the offending product/tool attached. This fails fast to stderr and
+// makes missing envelopes / supplement gaps immediately visible.
+func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
+ dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
+ if dryRun {
+ invocation.DryRun = true
+ return r.fallback.Run(ctx, invocation)
+ }
+ return executor.Result{}, apperrors.NewAPI(
+ fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
+ apperrors.WithOperation("discovery.resolve"),
+ apperrors.WithReason("endpoint_not_resolved"),
+ apperrors.WithServerKey(invocation.CanonicalProduct),
+ apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
+ apperrors.WithActions("dws cache refresh"),
+ )
+}
+
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go
index f5267063..3f0b4cc0 100644
--- a/internal/app/runner_test.go
+++ b/internal/app/runner_test.go
@@ -26,6 +26,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
@@ -33,6 +34,13 @@ import (
func setupRuntimeCommandTest(t *testing.T) {
t.Helper()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+ // Isolate keychain storage so concurrent test packages can't leak a
+ // real auth token into runtime tests via the shared on-disk keychain
+ // location. We deliberately do NOT reset the process-wide token
+ // cache here: getCachedRuntimeToken uses sync.Once and is read by
+ // detached preload goroutines spawned per Run(); replacing the Once
+ // races with those still-running goroutines.
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(contactDiscoveryResponse())
@@ -336,7 +344,17 @@ func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
}
}
-func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
+// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix
+// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog
+// does not carry the requested product (here `contact` against a
+// doc-only fixture), `runtimeRunner.Run` must surface an explicit
+// `endpoint_not_resolved` error instead of silently echoing an empty result
+// back to the user — the latter is what historically produced `{"Content":
+// null}` output on `dws doc list`.
+//
+// Dry-run / compat semantics continue to route through EchoRunner and are
+// covered by other tests in this file (TestRuntime*Dry*, TestCompat*).
+func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
defer server.Close()
@@ -349,33 +367,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
cmd.SetErr(&out)
cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"})
- if err := cmd.Execute(); err != nil {
- t.Fatalf("Execute() error = %v", err)
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String())
}
-
- var payload struct {
- Invocation struct {
- Implemented bool `json:"implemented"`
- CanonicalProduct string `json:"canonical_product"`
- Tool string `json:"tool"`
- } `json:"invocation"`
- Response map[string]any `json:"response"`
+ msg := err.Error()
+ if !strings.Contains(msg, "endpoint not resolved") {
+ t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg)
}
- if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
- t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
- }
-
- if payload.Invocation.Implemented {
- t.Fatalf("implemented = true, want false for fallback")
- }
- if payload.Invocation.CanonicalProduct != "contact" {
- t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct)
- }
- if payload.Invocation.Tool != "get_current_user_profile" {
- t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool)
- }
- if payload.Response != nil {
- t.Fatalf("response = %#v, want nil for echo fallback", payload.Response)
+ if !strings.Contains(msg, "contact") {
+ t.Fatalf("error message = %q, want it to mention product \"contact\"", msg)
}
}
diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go
new file mode 100644
index 00000000..75333de3
--- /dev/null
+++ b/internal/app/testmain_test.go
@@ -0,0 +1,50 @@
+// 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 (
+ "os"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
+)
+
+// TestMain isolates the on-disk keychain storage to a process-wide
+// temporary directory for the entire internal/app test binary.
+//
+// Background: getCachedRuntimeToken caches the auth token via sync.Once
+// for the process lifetime. Whichever test triggers it first locks in the
+// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget)
+// call SaveTokenData and then exec a CLI command that triggers Once.Do; if
+// keychain storage points at the developer's real ~/Library/Application
+// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be
+// written there and cached process-wide, breaking later tests that assume
+// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest.
+//
+// Setting keychain.StorageDirEnv here forces every keychain read/write in
+// this binary into a per-process tempdir, eliminating that contamination
+// without touching production code.
+func TestMain(m *testing.M) {
+ tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
+ if err != nil {
+ panic("create test keychain tempdir: " + err.Error())
+ }
+ if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
+ _ = os.RemoveAll(tmpDir)
+ panic("set " + keychain.StorageDirEnv + ": " + err.Error())
+ }
+ code := m.Run()
+ _ = os.RemoveAll(tmpDir)
+ os.Exit(code)
+}
diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go
index 95daf49f..3617e84f 100644
--- a/internal/app/tool_caller_adapter.go
+++ b/internal/app/tool_caller_adapter.go
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
+ "log/slog"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -56,6 +57,19 @@ func (a *toolCallerAdapter) DryRun() bool {
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
+ // After the fix-wukong-discovery-missing-servers Phase 3 change,
+ // runtimeRunner.Run returns an explicit error for catalog misses
+ // instead of an empty Response, so this branch should only be
+ // reachable for unit tests / unexpected runners. Log a warning so
+ // any future regression (silent `{"Content": null}` on the CLI)
+ // leaves a trace in the file logger / stderr.
+ slog.Warn(
+ "tool_caller_adapter: empty runner response — upstream should surface an error instead",
+ "product", r.Invocation.CanonicalProduct,
+ "tool", r.Invocation.Tool,
+ "kind", r.Invocation.Kind,
+ "dry_run", r.Invocation.DryRun,
+ )
return &edition.ToolResult{}
}
diff --git a/internal/auth/credential_reset_test.go b/internal/auth/credential_reset_test.go
new file mode 100644
index 00000000..9542a775
--- /dev/null
+++ b/internal/auth/credential_reset_test.go
@@ -0,0 +1,319 @@
+package auth
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// resetAppConfigCache clears cached app config so tests get a fresh load.
+func resetAppConfigCache() {
+ cachedAppConfigMu.Lock()
+ cachedAppConfig = nil
+ cachedAppConfigMu.Unlock()
+ cachedAppConfigOnce = sync.Once{}
+
+ cachedResolvedMu.Lock()
+ cachedResolvedValid = false
+ cachedResolvedID = ""
+ cachedResolvedSecret = ""
+ cachedResolvedMu.Unlock()
+}
+
+// ─── Issue #155: Defensive credential reset ────────────────────────────
+//
+// These tests verify that both DeviceFlowProvider and OAuthProvider always
+// reset credential state and re-fetch clientID from MCP, regardless of what
+// previous login methods left in app.json or runtime state.
+
+func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
+ // Scenario: OAuth login saved app.json WITHOUT Source field (the original bug).
+ // Device flow should ignore the stale clientID, reset state, and re-fetch from MCP.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ // Simulate OAuth login: saved app.json with clientId but NO Source field
+ oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
+ if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil {
+ t.Fatalf("WriteFile error: %v", err)
+ }
+
+ // Verify precondition: ClientID() loads stale value from app.json
+ resetAppConfigCache()
+ gotID := ClientID()
+ if gotID != "ding-oauth-stale" {
+ t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source")
+ }
+
+ // Now create a DeviceFlowProvider — it should pick up the stale clientID
+ provider := NewDeviceFlowProvider(dir, nil)
+ if provider.clientID != "ding-oauth-stale" {
+ t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID)
+ }
+
+ // Key assertion: after prepareCredentials(), the provider should have
+ // cleared the stale clientID and be ready for MCP fetch.
+ // We can't call Login() directly (needs real MCP server), but we can
+ // verify the reset logic by calling the new method directly.
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
+ }
+}
+
+func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
+ // Scenario: Legacy app.json (no Source field at all) exists from an old CLI version.
+ // Device flow should reset and re-fetch.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ // Write legacy app.json
+ legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}`
+ if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil {
+ t.Fatalf("WriteFile error: %v", err)
+ }
+
+ resetAppConfigCache()
+ provider := NewDeviceFlowProvider(dir, nil)
+
+ // Verify stale clientID was loaded
+ if provider.clientID != "ding-legacy-old" {
+ t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID)
+ }
+
+ // Reset should clear it
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+}
+
+func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
+ // Scenario: User previously logged in with --client-id + --client-secret (direct mode).
+ // app.json has a different clientId. Device flow should reset and re-fetch from MCP.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ // Simulate direct-mode app.json with clientSecret stored
+ if err := SaveAppConfig(dir, &AppConfig{
+ ClientID: "ding-direct-custom",
+ ClientSecret: PlainSecret("some-secret"),
+ }); err != nil {
+ t.Fatalf("SaveAppConfig error: %v", err)
+ }
+
+ resetAppConfigCache()
+ provider := NewDeviceFlowProvider(dir, nil)
+
+ // Verify the direct clientID was loaded
+ if provider.clientID != "ding-direct-custom" {
+ t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID)
+ }
+
+ // Reset should clear it
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
+ }
+}
+
+func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) {
+ // Scenario: MCP flag is already set from a previous device login in same process.
+ // Device flow should still reset and re-fetch to ensure freshness.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ // Simulate: MCP flag is already set from previous login
+ SetClientIDFromMCP("ding-old-mcp")
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ provider := NewDeviceFlowProvider(dir, nil)
+ if provider.clientID != "ding-old-mcp" {
+ t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID)
+ }
+
+ // Reset should clear both clientID and MCP flag
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
+ }
+}
+
+func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) {
+ // Scenario: No app.json exists (first time login). Device flow should work normally.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ provider := NewDeviceFlowProvider(dir, nil)
+
+ // clientID should already be empty
+ if provider.clientID != "" {
+ t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID)
+ }
+
+ // Reset should be a no-op but not crash
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
+ }
+}
+
+// ─── OAuthProvider defensive reset (--force login) ─────────────────────
+
+func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) {
+ // Scenario: Previous login saved app.json with MCP-fetched clientID but
+ // no Source marker. OAuth --force login should reset and re-fetch.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ // Simulate previous login: app.json with clientId but no Source
+ staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
+ if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil {
+ t.Fatalf("WriteFile error: %v", err)
+ }
+
+ resetAppConfigCache()
+ provider := NewOAuthProvider(dir, nil)
+
+ // Verify stale clientID was loaded
+ if provider.clientID != "ding-stale-oauth" {
+ t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID)
+ }
+
+ // Reset should clear it
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
+ }
+}
+
+func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) {
+ // Scenario: MCP flag is already set. OAuth --force login should still reset.
+
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+
+ SetClientIDFromMCP("ding-old-mcp-oauth")
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ provider := NewOAuthProvider(dir, nil)
+
+ provider.resetCredentialState()
+
+ if provider.clientID != "" {
+ t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
+ }
+ if IsClientIDFromMCP() {
+ t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
+ }
+}
diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go
index 5b56a079..d14741bd 100644
--- a/internal/auth/device_flow.go
+++ b/internal/auth/device_flow.go
@@ -127,18 +127,43 @@ type serviceResult struct {
ErrorMsg string `json:"errorMsg"`
}
+// resetCredentialState clears any stale credential state inherited from
+// previous login methods (OAuth, PAT, etc.) so that device flow always
+// starts fresh by fetching clientID from MCP.
+//
+// This is a defensive measure: no matter what a prior login wrote to
+// app.json or runtime globals, device flow will re-fetch from MCP and
+// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
+// the MCP proxy path (which doesn't require clientSecret).
+func (p *DeviceFlowProvider) resetCredentialState() {
+ p.clientID = ""
+ clientMu.Lock()
+ clientIDFromMCP = false
+ clientMu.Unlock()
+}
+
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
- // Ensure we have a valid client ID (fetch from MCP if not available)
- if p.clientID == "" {
+ // Defensive reset: clear stale credential state from previous login methods,
+ // but preserve user-provided --client-id if present.
+ userClientID := p.clientID
+ p.resetCredentialState()
+
+ if userClientID != "" && userClientID != DefaultClientID {
+ // User provided --client-id flag: use it directly, skip MCP fetch.
+ p.clientID = userClientID
if p.logger != nil {
- p.logger.Debug("client ID not configured, fetching from MCP server")
+ p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
+ }
+ } else {
+ // No user-provided client ID: fetch from MCP server.
+ if p.logger != nil {
+ p.logger.Debug("fetching client ID from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
- // Mark that clientID is from MCP
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
diff --git a/internal/auth/force_refresh.go b/internal/auth/force_refresh.go
new file mode 100644
index 00000000..05504b43
--- /dev/null
+++ b/internal/auth/force_refresh.go
@@ -0,0 +1,41 @@
+// 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 auth
+
+import "time"
+
+// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
+// instant (preserving access_token and refresh_token), and writes it back. The
+// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
+// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
+// access_token.
+//
+// Use this only when the server has rejected the current access_token but the
+// local expiry has not yet elapsed (zombie token scenario). It does not delete
+// any token material and is safe to call concurrently — actual refresh is
+// serialized by lockedRefresh's dual-layer locking.
+//
+// Returns the original load error when there is no usable token on disk; a
+// nil error when there is no access_token to invalidate (no-op).
+func MarkAccessTokenStale(configDir string) error {
+ data, err := LoadTokenData(configDir)
+ if err != nil {
+ return err
+ }
+ if data == nil || data.AccessToken == "" {
+ return nil
+ }
+ data.ExpiresAt = time.Now().Add(-1 * time.Minute)
+ return SaveTokenData(configDir, data)
+}
diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go
index d7034ee4..ee472838 100644
--- a/internal/auth/oauth_provider.go
+++ b/internal/auth/oauth_provider.go
@@ -55,6 +55,16 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
}
}
+// resetCredentialState clears any stale credential state inherited from
+// previous login methods so that OAuth flow always starts fresh by
+// fetching clientID from MCP.
+func (p *OAuthProvider) resetCredentialState() {
+ p.clientID = ""
+ clientMu.Lock()
+ clientIDFromMCP = false
+ clientMu.Unlock()
+}
+
func (p *OAuthProvider) output() io.Writer {
if p != nil && p.Output != nil {
return p.Output
@@ -75,6 +85,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("access_token still valid, skipping login")
}
+ // Even on early return, persist custom app credentials if provided
+ // via --client-id/--client-secret flags. Without this, the flags
+ // are only in runtime globals and lost when the process exits.
+ p.persistAppConfigIfNeeded()
return data, nil
}
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
@@ -84,6 +98,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
refreshed, rErr := p.lockedRefresh(ctx)
if rErr == nil {
+ p.persistAppConfigIfNeeded()
return refreshed, nil
}
if p.logger != nil {
@@ -94,17 +109,27 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Fall through: full browser OAuth flow.
- // Ensure we have a valid client ID (fetch from MCP if not available)
- if p.clientID == "" {
+ // Defensive reset: clear stale credential state from previous login methods,
+ // but preserve user-provided --client-id if present.
+ userClientID := p.clientID
+ p.resetCredentialState()
+
+ if userClientID != "" && userClientID != DefaultClientID {
+ // User provided --client-id flag: use it directly, skip MCP fetch.
+ p.clientID = userClientID
if p.logger != nil {
- p.logger.Debug("client ID not configured, fetching from MCP server")
+ p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
+ }
+ } else {
+ // No user-provided client ID: fetch from MCP server.
+ if p.logger != nil {
+ p.logger.Debug("fetching client ID from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
- // Mark that clientID is from MCP, so we use MCP OAuth endpoints
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go
new file mode 100644
index 00000000..92310f85
--- /dev/null
+++ b/internal/auth/testmain_test.go
@@ -0,0 +1,40 @@
+// 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 auth
+
+import (
+ "os"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
+)
+
+// TestMain isolates the on-disk keychain storage to a process-wide
+// temporary directory for the entire internal/auth test binary so that
+// SaveTokenData/DeleteTokenData calls in these tests can never write to
+// the developer's real keychain location, preventing cross-package leaks
+// when go test runs packages in parallel.
+func TestMain(m *testing.M) {
+ tmpDir, err := os.MkdirTemp("", "dws-auth-test-keychain-")
+ if err != nil {
+ panic("create test keychain tempdir: " + err.Error())
+ }
+ if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
+ _ = os.RemoveAll(tmpDir)
+ panic("set " + keychain.StorageDirEnv + ": " + err.Error())
+ }
+ code := m.Run()
+ _ = os.RemoveAll(tmpDir)
+ os.Exit(code)
+}
diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go
index 5dcc87f5..33f77caa 100644
--- a/internal/auth/token_test.go
+++ b/internal/auth/token_test.go
@@ -20,9 +20,12 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
-// cleanupKeychain removes test data from keychain after test completes.
+// cleanupKeychain isolates keychain state to a per-test temporary directory
+// so that concurrent test packages (notably internal/app) don't read tokens
+// written by these tests, and removes test data on completion.
func cleanupKeychain(t *testing.T) {
t.Helper()
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go
index 4f7bdaeb..86b23282 100644
--- a/internal/cli/canonical.go
+++ b/internal/cli/canonical.go
@@ -104,22 +104,38 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
}
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
- return &cobra.Command{
- Use: "schema [product.tool]",
+ cmd := &cobra.Command{
+ Use: "schema [path]",
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
-不带参数时列出所有产品及其工具数量;带 product.tool 路径时
-输出该工具的完整输入 Schema(JSON Schema 格式)。
+不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
+输入 Schema(JSON Schema 格式)、输出 Schema、MCP 注解和 CLI
+层的 flag overlay(alias/transform/env_default)。
+
+路径支持三种写法:
+ product.rpc_name 规范路径 (e.g. ding.send_ding_message)
+ product.group.cli_name CLI 点路径 (e.g. ding.message.send)
+ "product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send")
示例:
- dws schema # 列出所有产品
- dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema
- dws schema --fields id,tools # 只显示 id 和 tools 字段
- dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`,
+ dws schema # 列出所有产品
+ dws schema ding.send_ding_message # 规范路径
+ dws schema "ding message send" # CLI 路径(空格)
+ dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
+ dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
+ dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ cliPath, _ := cmd.Flags().GetString("cli-path")
+ cliPath = strings.TrimSpace(cliPath)
+ if cliPath != "" {
+ if len(args) > 0 {
+ return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive")
+ }
+ args = []string{cliPath}
+ }
catalog, err := loader.Load(cmd.Context())
if err != nil {
var degraded *CatalogDegraded
@@ -158,6 +174,8 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
)
},
}
+ cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
+ return cmd
}
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
@@ -664,7 +682,7 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
}, nil
}
- product, tool, ok := catalog.FindTool(args[0])
+ product, tool, ok := resolveSchemaPath(catalog, args[0])
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
}
@@ -676,23 +694,100 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
}, nil
}
-// compactTool returns a lean representation of a tool for schema
-// output, keeping only the fields that AI agents and developers
-// need: name, description, parameters, and sensitivity flag.
-func compactTool(t ir.ToolDescriptor) map[string]any {
- tool := map[string]any{
- "name": t.RPCName,
- "title": t.Title,
- "description": t.Description,
- "sensitive": t.Sensitive,
+// resolveSchemaPath accepts three input forms and maps to (product, tool):
+// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message")
+// - "product.cli_name" (single-level CLI path, e.g. "doc.create")
+// - CLI path with group ("ding message send" or "ding.message.send";
+// also accepts "/" and multiple whitespace between tokens)
+//
+// Canonical form is tried first so existing callers and scripts keep
+// working; only when that fails does the CLI-path resolver run.
+func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
+ if product, tool, ok := catalog.FindTool(raw); ok {
+ return product, tool, true
+ }
+
+ tokens := splitSchemaPathTokens(raw)
+ if len(tokens) < 2 {
+ return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
+ }
+
+ productID := tokens[0]
+ leaf := tokens[len(tokens)-1]
+ groupPath := strings.Join(tokens[1:len(tokens)-1], ".")
+
+ product, ok := catalog.FindProduct(productID)
+ if !ok {
+ return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
+ }
+
+ for _, tool := range product.Tools {
+ if tool.CLIName != leaf {
+ continue
+ }
+ if strings.TrimSpace(tool.Group) != groupPath {
+ continue
+ }
+ return product, tool, true
+ }
+ return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
+}
+
+// splitSchemaPathTokens splits a CLI path on dots, slashes, and
+// whitespace, returning only non-empty tokens. "ding message send",
+// "ding.message.send", and "ding/message/send" all yield the same
+// three tokens.
+func splitSchemaPathTokens(raw string) []string {
+ fields := strings.FieldsFunc(raw, func(r rune) bool {
+ return r == '.' || r == '/' || r == ' ' || r == '\t'
+ })
+ out := fields[:0]
+ for _, f := range fields {
+ if s := strings.TrimSpace(f); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+// compactTool returns a lean representation of a tool for schema
+// output, keeping the fields AI agents and scripts need: RPC + CLI
+// identity, input/output schema, sensitivity, MCP annotations, and the
+// CLI flag overlay (alias/transform/envDefault/default) that shapes
+// how raw MCP parameters appear on the command line.
+func compactTool(t ir.ToolDescriptor) map[string]any {
+ tool := map[string]any{
+ "name": t.RPCName,
+ "cli_name": t.CLIName,
+ "canonical_path": t.CanonicalPath,
+ "title": t.Title,
+ "description": t.Description,
+ "sensitive": t.Sensitive,
+ }
+
+ if strings.TrimSpace(t.Group) != "" {
+ tool["group"] = t.Group
+ }
if props, ok := t.InputSchema["properties"]; ok {
tool["parameters"] = props
}
if req := requiredFields(t.InputSchema); len(req) > 0 {
tool["required"] = req
}
+ if len(t.OutputSchema) > 0 {
+ tool["output_schema"] = t.OutputSchema
+ }
+ if t.Annotations != nil {
+ tool["annotations"] = t.Annotations
+ }
+ if len(t.FlagOverlay) > 0 {
+ tool["flag_overlay"] = t.FlagOverlay
+ }
return tool
}
diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go
index 24ce2aec..84aa72df 100644
--- a/internal/cli/canonical_test.go
+++ b/internal/cli/canonical_test.go
@@ -121,6 +121,205 @@ func TestSchemaPayloadFindsTool(t *testing.T) {
}
}
+func TestCompactToolEmitsExtendedFields(t *testing.T) {
+ t.Parallel()
+
+ destructive := true
+ tool := ir.ToolDescriptor{
+ RPCName: "send_ding_message",
+ CLIName: "send",
+ Group: "message",
+ CanonicalPath: "ding.send_ding_message",
+ Title: "发送DING消息",
+ Description: "desc",
+ Sensitive: true,
+ InputSchema: map[string]any{
+ "type": "object",
+ "required": []any{"robotCode"},
+ "properties": map[string]any{
+ "robotCode": map[string]any{"type": "string"},
+ },
+ },
+ OutputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "openDingId": map[string]any{"type": "string"},
+ },
+ },
+ Annotations: &ir.ToolAnnotations{DestructiveHint: &destructive},
+ FlagOverlay: map[string]ir.FlagOverlay{
+ "receiverUserIdList": {Alias: "users", Transform: "csv_to_array"},
+ },
+ }
+
+ out := compactTool(tool)
+ if out["name"] != "send_ding_message" {
+ t.Errorf("name = %v", out["name"])
+ }
+ if out["cli_name"] != "send" {
+ t.Errorf("cli_name = %v", out["cli_name"])
+ }
+ if out["canonical_path"] != "ding.send_ding_message" {
+ t.Errorf("canonical_path = %v", out["canonical_path"])
+ }
+ if out["group"] != "message" {
+ t.Errorf("group = %v", out["group"])
+ }
+ if _, ok := out["output_schema"]; !ok {
+ t.Errorf("output_schema missing, keys = %v", keysOf(out))
+ }
+ if _, ok := out["annotations"]; !ok {
+ t.Errorf("annotations missing, keys = %v", keysOf(out))
+ }
+ overlay, ok := out["flag_overlay"].(map[string]ir.FlagOverlay)
+ if !ok {
+ t.Fatalf("flag_overlay type = %T", out["flag_overlay"])
+ }
+ if overlay["receiverUserIdList"].Alias != "users" {
+ t.Errorf("overlay alias = %q", overlay["receiverUserIdList"].Alias)
+ }
+}
+
+func TestCompactToolOmitsEmptyExtras(t *testing.T) {
+ t.Parallel()
+
+ tool := ir.ToolDescriptor{
+ RPCName: "list_documents",
+ CLIName: "list",
+ CanonicalPath: "doc.list_documents",
+ InputSchema: map[string]any{"type": "object"},
+ }
+ out := compactTool(tool)
+ for _, key := range []string{"output_schema", "annotations", "flag_overlay", "group"} {
+ if _, has := out[key]; has {
+ t.Errorf("key %q should be omitted when empty, got %#v", key, out[key])
+ }
+ }
+}
+
+func TestSchemaPayloadResolvesCLIPath(t *testing.T) {
+ t.Parallel()
+
+ catalog := ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "ding",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_ding_message",
+ CLIName: "send",
+ Group: "message",
+ CanonicalPath: "ding.send_ding_message",
+ InputSchema: map[string]any{"type": "object"},
+ },
+ {
+ RPCName: "recall_ding_message",
+ CLIName: "recall",
+ Group: "message",
+ CanonicalPath: "ding.recall_ding_message",
+ InputSchema: map[string]any{"type": "object"},
+ },
+ },
+ },
+ },
+ }
+
+ cases := []struct {
+ name string
+ input string
+ wantRPC string
+ wantErr bool
+ }{
+ {"canonical rpc path", "ding.send_ding_message", "send_ding_message", false},
+ {"dotted cli path", "ding.message.send", "send_ding_message", false},
+ {"space cli path", "ding message send", "send_ding_message", false},
+ {"slash cli path", "ding/message/recall", "recall_ding_message", false},
+ {"unknown leaf", "ding message nope", "", true},
+ {"unknown group", "ding random send", "", true},
+ {"unknown product", "nope send", "", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ payload, err := schemaPayload(catalog, []string{tc.input})
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
+ }
+ if tc.wantErr {
+ return
+ }
+ tool, ok := payload["tool"].(map[string]any)
+ if !ok {
+ t.Fatalf("payload tool missing, got %#v", payload)
+ }
+ if tool["name"] != tc.wantRPC {
+ t.Errorf("tool name = %v, want %s", tool["name"], tc.wantRPC)
+ }
+ })
+ }
+}
+
+func TestSchemaCommandCLIPathFlag(t *testing.T) {
+ t.Parallel()
+
+ loader := StaticLoader{Catalog: ir.Catalog{
+ Products: []ir.CanonicalProduct{{
+ ID: "ding",
+ Tools: []ir.ToolDescriptor{{
+ RPCName: "send_ding_message",
+ CLIName: "send",
+ Group: "message",
+ CanonicalPath: "ding.send_ding_message",
+ InputSchema: map[string]any{"type": "object"},
+ }},
+ }},
+ }}
+
+ t.Run("resolves via --cli-path", func(t *testing.T) {
+ cmd := NewSchemaCommand(loader)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--cli-path", "ding message send"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ tool, ok := payload["tool"].(map[string]any)
+ if !ok {
+ t.Fatalf("tool missing: %#v", payload)
+ }
+ if tool["name"] != "send_ding_message" {
+ t.Errorf("tool name = %v", tool["name"])
+ }
+ })
+
+ t.Run("rejects positional + flag collision", func(t *testing.T) {
+ cmd := NewSchemaCommand(loader)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--cli-path", "ding message send", "ding.send_ding_message"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatalf("expected mutual-exclusion error, got nil")
+ }
+ if !strings.Contains(err.Error(), "mutually exclusive") {
+ t.Errorf("err = %v, want mutual-exclusion message", err)
+ }
+ })
+}
+
+func keysOf(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
func TestNewMCPCommandReturnsLoaderErrorForInvocations(t *testing.T) {
t.Parallel()
diff --git a/internal/cli/loader.go b/internal/cli/loader.go
index 28c37942..e3ff5c81 100644
--- a/internal/cli/loader.go
+++ b/internal/cli/loader.go
@@ -250,6 +250,13 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
}
+ // Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps,
+ // dangling toolOverrides.*.serverOverride) to stderr so configuration drift
+ // is visible at the first `dws cache refresh` / cold start after Portal
+ // publishes a broken envelope. Non-fatal: discovery continues with the
+ // accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
+ logDiscoveryWarnings(response.Metadata.Warnings)
+
servers := market.NormalizeServers(response, "live_market")
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
@@ -354,3 +361,19 @@ func (l EnvironmentLoader) lookup(key string) (string, bool) {
}
return value, true
}
+
+// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr).
+// No-op when the response carries an empty / nil Warnings slice — which is the
+// common case for older Portal builds that don't populate the field.
+func logDiscoveryWarnings(warnings []market.ListWarning) {
+ if len(warnings) == 0 {
+ return
+ }
+ for _, w := range warnings {
+ slog.Warn("discovery: merge warning from Portal",
+ "product", w.ProductID,
+ "reason", w.Reason,
+ "detail", w.Detail,
+ )
+ }
+}
diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go
index ba0d57d8..b72b1b7c 100644
--- a/internal/compat/dynamic_commands.go
+++ b/internal/compat/dynamic_commands.go
@@ -14,18 +14,35 @@
package compat
import (
+ "context"
"encoding/json"
"fmt"
+ "log/slog"
"os"
"sort"
"strings"
+ "time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
+// runtimeDefaultWhitelist is the closed set of placeholders v3 supports for
+// CLIFlagOverride.RuntimeDefault. Placeholders outside this set emit a
+// warning at command-build time and are ignored at invocation time. See
+// discovery-schema-v3 §2.3.
+var runtimeDefaultWhitelist = map[string]bool{
+ "$currentUserId": true,
+ "$unionId": true,
+ "$corpId": true,
+ "$now": true,
+ "$today": true,
+}
+
// BuildDynamicCommands generates cobra commands from servers.json CLIOverlay metadata.
// Each server with non-skip CLIOverlay gets a top-level command with groups and
// tool overrides translated into subcommands with proper flag bindings and transforms.
@@ -47,10 +64,6 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
if cli.Skip {
continue
}
- if len(cli.ToolOverrides) == 0 {
- continue
- }
-
// §1.1: cli.command → top-level command name
cmdName := strings.TrimSpace(cli.Command)
if cmdName == "" {
@@ -60,11 +73,35 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
continue
}
+ // §v3.2.6: CLIOverlay.RedirectTo turns the entire top-level product
+ // into a stub that prints "Please use: dws ".
+ if target := strings.TrimSpace(cli.RedirectTo); target != "" {
+ stub := buildOverlayRedirect(cmdName, cli.Description, target)
+ if cli.Hidden {
+ stub.Hidden = true
+ }
+ // Mark envelope provenance so overlay registrants can tell this
+ // redirect stub apart from a helper fallback carrying the same
+ // name; see cmdutil.SourceAnnotation.
+ cmdutil.MarkEnvelopeSource(stub)
+ built = append(built, builtCmd{cmd: stub, parent: strings.TrimSpace(cli.Parent)})
+ continue
+ }
+
+ if len(cli.ToolOverrides) == 0 {
+ continue
+ }
+
rootCmd := NewGroupCommand(cmdName, cli.Description)
// §1.5: cli.hidden → entire service hidden
if cli.Hidden {
rootCmd.Hidden = true
}
+ // Mark envelope provenance so edition overlays can distinguish this
+ // dynamic root from a same-named helper fallback when deciding
+ // whether to merge hardcoded leaves or evict and replace. See
+ // cmdutil.SourceAnnotation and the wukong overlay's RegisterProducts.
+ cmdutil.MarkEnvelopeSource(rootCmd)
// Build detail index for this server: toolName → DetailTool
detailIndex := buildDetailIndex(detailsByID[strings.TrimSpace(cli.ID)])
@@ -93,6 +130,13 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cliName = deriveCommandName(toolName, cli.Prefixes)
}
+ // §P2.redirect: redirectTo turns this entry into a stub.
+ if target := strings.TrimSpace(override.RedirectTo); target != "" {
+ redirect := buildRedirectCommand(cliName, override.Description, target)
+ attachToGroup(rootCmd, override.Group, groupCmds, redirect)
+ continue
+ }
+
bindings, normalizer := buildOverrideBindings(override)
// Resolve Short/Long from Detail API toolTitle/toolDesc;
@@ -111,12 +155,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
}
}
+ // ServerOverride routes this leaf's tool invocation to a different
+ // product's MCP server; fall back to the enclosing overlay's ID.
+ canonicalProduct := strings.TrimSpace(override.ServerOverride)
+ if canonicalProduct == "" {
+ canonicalProduct = strings.TrimSpace(cli.ID)
+ }
+
route := Route{
Use: cliName,
Short: short,
Long: long,
+ // Preserve left-side indentation: cobra's Examples template
+ // renders {{.Example}} verbatim, and hardcoded helper commands
+ // rely on a 2-space prefix to look indented under "Examples:".
+ // Only trim trailing whitespace/newlines so envelope JSON can
+ // safely carry a closing "\n" without doubling the blank line.
+ Example: strings.TrimRight(override.Example, " \t\r\n"),
Target: Target{
- CanonicalProduct: strings.TrimSpace(cli.ID),
+ CanonicalProduct: canonicalProduct,
Tool: toolName,
},
Bindings: bindings,
@@ -128,6 +185,9 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
route.Normalizer = chainSensitiveNormalizer(normalizer)
}
+ // §v3.2.5: outputFormat.rename/drop/columns post-processing.
+ route.OutputTransform = buildOutputTransform(override.OutputFormat)
+
cmd := NewDirectCommand(route, runner)
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
@@ -135,13 +195,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
}
+ // §P2.flagconstraints: must run AFTER schema enrichment because the
+ // target flags may be registered lazily by buildFlagsFromDetailSchema.
+ applyFlagConstraints(cmd, override)
+
// §1.4: Add to the right parent group
- groupName := strings.TrimSpace(override.Group)
- if groupName != "" {
- parent := resolveNestedGroup(rootCmd, groupName, groupCmds)
- parent.AddCommand(cmd)
- } else {
- rootCmd.AddCommand(cmd)
+ attachToGroup(rootCmd, override.Group, groupCmds, cmd)
+ }
+
+ // §P2.hints: attach hint stub commands registered on the overlay.
+ if len(cli.Hints) > 0 {
+ hintNames := make([]string, 0, len(cli.Hints))
+ for name := range cli.Hints {
+ hintNames = append(hintNames, name)
+ }
+ sort.Strings(hintNames)
+ for _, name := range hintNames {
+ def := cli.Hints[name]
+ hint := buildHintCommand(name, def)
+ attachToGroup(rootCmd, def.Group, groupCmds, hint)
}
}
@@ -166,7 +238,7 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
}
for _, child := range children {
if parent, ok := topLevel[child.parent]; ok {
- parent.AddCommand(child.cmd)
+ attachOrMerge(parent, child.cmd)
} else {
// Parent not found among dynamic commands; emit as top-level.
name := child.cmd.Name()
@@ -238,6 +310,15 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
for _, key := range keys {
prop := schema.Properties[key]
+ // Skip properties that are bound as positional arguments; they are
+ // collected from cobra args rather than flags. For dual-mode positional
+ // bindings (envelope: positional + alias/aliases), ApplyBindings has
+ // already registered the alias flags, so we should not re-register
+ // them here from the MCP detail schema.
+ if ov, ok := flagOverrides[key]; ok && ov.Positional {
+ continue
+ }
+
// Determine flag name: prefer alias from CLIFlagOverride, else kebab-case.
flagName := toKebabCase(key)
if ov, ok := flagOverrides[key]; ok && strings.TrimSpace(ov.Alias) != "" {
@@ -389,6 +470,43 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
return ensureNestedGroup(root, groupPath, groupPath, registry)
}
+// attachOrMerge adds child as a sub-command of parent. If parent already has a
+// sub-command with the same Name(), the two are merged recursively: child's
+// sub-commands are moved onto the existing one and child itself is discarded.
+// Leaf collisions (two commands with the same Name and no further children)
+// are resolved first-wins — the incoming one is dropped.
+//
+// This lets multiple server entries share a cli.command under the same parent,
+// e.g. bot-message (command="message", parent="chat") can contribute leaves
+// into the same "message" subtree already built from chat's own toolOverrides,
+// without creating a duplicate "message" sibling in chat's help output.
+func attachOrMerge(parent, child *cobra.Command) {
+ existing := findSubcommand(parent, child.Name())
+ if existing == nil {
+ parent.AddCommand(child)
+ return
+ }
+ // Snapshot child's sub-commands before we start moving them (RemoveCommand
+ // mutates the slice we'd be iterating).
+ subs := make([]*cobra.Command, len(child.Commands()))
+ copy(subs, child.Commands())
+ for _, sub := range subs {
+ child.RemoveCommand(sub)
+ attachOrMerge(existing, sub)
+ }
+}
+
+// findSubcommand returns the first sub-command of parent with the given name,
+// or nil if none match.
+func findSubcommand(parent *cobra.Command, name string) *cobra.Command {
+ for _, sub := range parent.Commands() {
+ if sub.Name() == name {
+ return sub
+ }
+ }
+ return nil
+}
+
// buildOverrideBindings converts CLIToolOverride flags into FlagBindings and
// constructs a Normalizer that applies transform rules.
// Implements §2.1-§2.5 of the conversion rules.
@@ -415,21 +533,46 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
envVar string
}
var envDefaults []envDefaultEntry
- type hiddenDefaultEntry struct {
+ // defaultInjectEntry captures envelope flag.default values that must be
+ // injected into the MCP body when the user omits the flag. v3.2 widened
+ // this from hidden-only to all flags so that visible flags carrying a
+ // default (e.g. oa list-forms cursor=0 / pageSize=100) match the
+ // hardcoded helper command behavior of `mustGetFlag(cobra default) →
+ // body`. The kind drives typed coercion at injection time so a
+ // `type: int` envelope default reaches MCP as `int(0)`, not string `"0"`.
+ type defaultInjectEntry struct {
paramName string
defaultValue string
+ kind ValueKind
}
- var hiddenDefaults []hiddenDefaultEntry
+ var defaultInjects []defaultInjectEntry
+ type runtimeDefaultEntry struct {
+ paramName string
+ placeholder string
+ }
+ var runtimeDefaults []runtimeDefaultEntry
+ type omitEntry struct {
+ paramName string
+ mode string // "empty" (default) | "zero" | "never"
+ }
+ omits := make(map[string]omitEntry, len(paramNames))
for _, paramName := range paramNames {
flagOverride := override.Flags[paramName]
- // §2.2: flag name from alias, fallback to kebab-case of param name
- flagName := strings.TrimSpace(flagOverride.Alias)
- if flagName == "" {
+ // §2.2: flag name from alias, fallback to kebab-case of param name.
+ // For pure positional bindings (no alias declared) we deliberately
+ // leave FlagName empty so ApplyBindings / NewDirectCommand can
+ // distinguish "envelope wants flag-or-positional dual entry" from
+ // "envelope only wants positional". Auto-deriving a flag name here
+ // would otherwise leak a redundant `--` flag and confuse
+ // the dual-mode detection.
+ explicitAlias := strings.TrimSpace(flagOverride.Alias)
+ flagName := explicitAlias
+ if flagName == "" && !flagOverride.Positional {
flagName = compatFlagName(paramName)
}
- if flagName == "" {
+ if flagName == "" && !flagOverride.Positional {
flagName = paramName
}
@@ -438,23 +581,68 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
continue
}
- binding := FlagBinding{
- FlagName: flagName,
- Property: paramName,
- Kind: ValueString,
- Usage: paramName,
+ // §2.2 (aliases): deduplicate additional hidden aliases against the
+ // primary name + reserved names. Preserves envelope declaration order
+ // so CLI precedence (primary > Alias > Aliases[0..n]) is deterministic.
+ var extraAliases []string
+ if len(flagOverride.Aliases) > 0 {
+ seen := map[string]bool{"json": true, "params": true}
+ if flagName != "" {
+ seen[flagName] = true
+ }
+ extraAliases = make([]string, 0, len(flagOverride.Aliases))
+ for _, a := range flagOverride.Aliases {
+ a = strings.TrimSpace(a)
+ if a == "" || seen[a] {
+ continue
+ }
+ seen[a] = true
+ extraAliases = append(extraAliases, a)
+ }
}
- // §2.5: hidden flag with default
- if flagOverride.Hidden {
- // Hidden flags are still added but marked hidden.
- // They are auto-populated with their default value via the normalizer.
- if flagOverride.Default != "" {
- hiddenDefaults = append(hiddenDefaults, hiddenDefaultEntry{
- paramName: paramName,
- defaultValue: flagOverride.Default,
- })
- }
+ // Usage defaults to paramName but an explicit Description on the
+ // overlay wins (it also beats the Detail API's toolDesc during flag
+ // enrichment because buildFlagsFromDetailSchema preserves overlay usage).
+ usage := paramName
+ if desc := strings.TrimSpace(flagOverride.Description); desc != "" {
+ usage = desc
+ }
+
+ binding := FlagBinding{
+ FlagName: flagName,
+ Aliases: extraAliases,
+ Short: strings.TrimSpace(flagOverride.Shorthand),
+ Property: paramName,
+ Kind: kindFromTypeName(flagOverride.Type),
+ Usage: usage,
+ // §P1: Required is preserved for positional bindings too. For
+ // pure positional, cobra arity (MinimumNArgs) enforces presence
+ // at parse time. For dual-mode positional (positional + alias),
+ // validateRequiredPositionalBindings closes the loop in RunE
+ // after both flag and positional injection, so MarkFlagRequired
+ // is intentionally avoided.
+ Required: flagOverride.Required,
+ // §2.4: Default drives both cobra's --help "(default ...)"
+ // rendering and (since v3.2) MCP body injection when the user
+ // omits the flag. CollectBindings still gates writes by
+ // user-changed flags, so user-provided values always win; the
+ // normalizer's defaultInjects loop only fills missing keys.
+ Default: flagOverride.Default,
+ Positional: flagOverride.Positional,
+ PositionalIndex: flagOverride.PositionalIndex,
+ }
+
+ // §v3.2: any non-empty default — hidden or visible — gets injected
+ // when the user omits the flag. Earlier versions gated this on
+ // flagOverride.Hidden which left visible flags with `"default": "0"`
+ // (e.g. oa list-forms cursor) silently absent from the MCP body.
+ if flagOverride.Default != "" {
+ defaultInjects = append(defaultInjects, defaultInjectEntry{
+ paramName: paramName,
+ defaultValue: flagOverride.Default,
+ kind: binding.Kind,
+ })
}
bindings = append(bindings, binding)
@@ -472,10 +660,23 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
envVar: flagOverride.EnvDefault,
})
}
+ if rd := strings.TrimSpace(flagOverride.RuntimeDefault); rd != "" {
+ if !runtimeDefaultWhitelist[rd] {
+ fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: unknown placeholder %q on %s; ignoring\n", rd, paramName)
+ } else {
+ runtimeDefaults = append(runtimeDefaults, runtimeDefaultEntry{
+ paramName: paramName,
+ placeholder: rd,
+ })
+ }
+ }
+ if mode := strings.TrimSpace(flagOverride.OmitWhen); mode != "" && mode != "empty" {
+ omits[paramName] = omitEntry{paramName: paramName, mode: mode}
+ }
}
// Check if we need a normalizer: transforms, env defaults, hidden defaults,
- // or dotted property paths that need nesting.
+ // runtime defaults, omit-when overrides, dotted property paths, or body wrapper.
needsDottedNesting := false
for _, b := range bindings {
if strings.Contains(b.Property, ".") {
@@ -483,16 +684,32 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
break
}
}
- if len(transforms) == 0 && len(envDefaults) == 0 && len(hiddenDefaults) == 0 && !needsDottedNesting {
+ bodyWrapper := strings.TrimSpace(override.BodyWrapper)
+ if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" {
return bindings, nil
}
- // Build a normalizer that applies hidden defaults + env defaults + transforms + nesting
+ // Build a normalizer that applies default injections + env defaults + runtime defaults
+ // + transforms + omitWhen + nesting + body wrap.
normalizer := func(cmd *cobra.Command, params map[string]any) error {
- // §2.5: Apply hidden flag defaults for parameters not explicitly set
- for _, hd := range hiddenDefaults {
- if _, exists := params[hd.paramName]; !exists {
- params[hd.paramName] = hd.defaultValue
+ // §v3.2: Apply envelope flag.default for parameters not explicitly set.
+ // Coerce by Kind so number-typed schemas don't reject string defaults.
+ for _, di := range defaultInjects {
+ if _, exists := params[di.paramName]; exists {
+ continue
+ }
+ defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(di.kind, di.defaultValue)
+ switch di.kind {
+ case ValueInt:
+ params[di.paramName] = defInt
+ case ValueFloat:
+ params[di.paramName] = defFloat
+ case ValueBool:
+ params[di.paramName] = defBool
+ case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
+ params[di.paramName] = defSlice
+ default: // ValueString, ValueJSON, and any unknown kind
+ params[di.paramName] = defStr
}
}
@@ -505,6 +722,26 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
}
}
+ // §v3.2.3: Apply runtime defaults (lowest priority, last default fill).
+ if len(runtimeDefaults) > 0 {
+ resolvers := runtimeDefaultResolvers()
+ for _, rd := range runtimeDefaults {
+ if _, exists := params[rd.paramName]; exists {
+ continue
+ }
+ resolver, ok := resolvers[rd.placeholder]
+ if !ok {
+ // Whitelisted but no provider registered (common on
+ // open-source core). Emit a single warning and move on.
+ fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: no resolver registered for %s; skipping %s\n", rd.placeholder, rd.paramName)
+ continue
+ }
+ if val, ok := resolver(cmd.Context()); ok && val != "" {
+ params[rd.paramName] = val
+ }
+ }
+ }
+
// §3: Apply transforms
for _, t := range transforms {
val, exists := params[t.paramName]
@@ -524,15 +761,155 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
params[t.paramName] = transformed
}
+ // §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
+ // condition for the declared mode. Default mode "empty" is already
+ // handled implicitly by CollectBindings (empty string / empty slice
+ // never enters params), so we only deal with "zero" and "never".
+ for _, o := range omits {
+ applyOmitWhen(params, o.paramName, o.mode)
+ }
+
// Nest dotted property paths: "Body.query" → params["Body"]["query"]
nestDottedPaths(params)
+ // §P2.bodyWrapper: wrap user-facing params under a single named key.
+ // Internal control keys (prefixed with '_' e.g. _blocked, _yes) stay
+ // at the top level so downstream confirmation logic keeps working.
+ if bodyWrapper != "" {
+ wrapParamsIntoBody(params, bodyWrapper)
+ }
+
return nil
}
return bindings, normalizer
}
+// wrapParamsIntoBody moves every non-internal key from params into a new
+// map stored under params[wrapper]. Internal keys (leading underscore) are
+// preserved at the top level so the dispatcher / --yes logic still sees
+// them. If params already contains params[wrapper] it is merged in first.
+func wrapParamsIntoBody(params map[string]any, wrapper string) {
+ if wrapper == "" {
+ return
+ }
+ body := map[string]any{}
+ if existing, ok := params[wrapper].(map[string]any); ok {
+ for k, v := range existing {
+ body[k] = v
+ }
+ delete(params, wrapper)
+ }
+ for key, value := range params {
+ if strings.HasPrefix(key, "_") {
+ continue
+ }
+ body[key] = value
+ delete(params, key)
+ }
+ params[wrapper] = body
+}
+
+// attachToGroup places cmd under the right parent based on the dotted group
+// path. Empty group means attach directly to the overlay root.
+func attachToGroup(root *cobra.Command, groupPath string, groupCmds map[string]*cobra.Command, cmd *cobra.Command) {
+ gp := strings.TrimSpace(groupPath)
+ if gp == "" {
+ root.AddCommand(cmd)
+ return
+ }
+ parent := resolveNestedGroup(root, gp, groupCmds)
+ parent.AddCommand(cmd)
+}
+
+// buildRedirectCommand returns a stub leaf that prints "use: " and
+// performs no tool invocation. Accepts unknown flags/args so users hitting
+// the old path get the redirect message instead of a parse error.
+func buildRedirectCommand(name, description, target string) *cobra.Command {
+ short := strings.TrimSpace(description)
+ if short == "" {
+ short = fmt.Sprintf("moved → %s", target)
+ }
+ cmd := &cobra.Command{
+ Use: name,
+ Short: short,
+ Long: fmt.Sprintf("This command has moved. Please use: %s", target),
+ DisableFlagParsing: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ fmt.Fprintf(cmd.OutOrStdout(), "This command has moved. Please use: %s\n", target)
+ return nil
+ },
+ }
+ return cmd
+}
+
+// buildHintCommand returns a stub sub-command that prints a redirect hint
+// to the canonical command path declared by the overlay's hintCommands entry.
+func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command {
+ target := strings.TrimSpace(def.Target)
+ short := strings.TrimSpace(def.Description)
+ if short == "" {
+ if target != "" {
+ short = fmt.Sprintf("hint: use %s", target)
+ } else {
+ short = "hint: see --help for the canonical command"
+ }
+ }
+ cmd := &cobra.Command{
+ Use: name,
+ Short: short,
+ Long: short,
+ DisableFlagParsing: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if target != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Please use: %s\n", target)
+ } else {
+ _ = cmd.Help()
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+// applyFlagConstraints wires mutuallyExclusive / requireOneOf declarations
+// onto the cobra command. Unknown flag names are logged and skipped so a
+// stale/malformed overlay never blocks the entire command tree from building.
+func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) {
+ validate := func(names []string) []string {
+ valid := make([]string, 0, len(names))
+ for _, n := range names {
+ n = strings.TrimSpace(n)
+ if n == "" {
+ continue
+ }
+ if cmd.Flags().Lookup(n) == nil {
+ fmt.Fprintf(os.Stderr, "[discovery] flag constraint references unknown flag --%s on %q; skipping\n", n, cmd.Name())
+ return nil
+ }
+ valid = append(valid, n)
+ }
+ return valid
+ }
+
+ for _, group := range override.MutuallyExclusive {
+ names := validate(group)
+ if len(names) < 2 {
+ continue
+ }
+ cmd.MarkFlagsMutuallyExclusive(names...)
+ }
+ for _, group := range override.RequireOneOf {
+ names := validate(group)
+ if len(names) < 1 {
+ continue
+ }
+ cmd.MarkFlagsOneRequired(names...)
+ }
+}
+
// chainSensitiveNormalizer wraps a normalizer with --yes confirmation for sensitive operations (§5.1).
func chainSensitiveNormalizer(inner Normalizer) Normalizer {
return func(cmd *cobra.Command, params map[string]any) error {
@@ -581,6 +958,101 @@ func sortedToolNames(m map[string]market.CLIToolOverride) []string {
return keys
}
+// kindFromTypeName maps the v3 CLIFlagOverride.Type declaration to the
+// internal FlagBinding.Kind enum. Empty / unknown → ValueString (which keeps
+// the v2 behaviour where every overlay flag was a plain string).
+func kindFromTypeName(typeName string) ValueKind {
+ switch strings.TrimSpace(strings.ToLower(typeName)) {
+ case "int", "integer", "number":
+ return ValueInt
+ case "bool", "boolean":
+ return ValueBool
+ case "stringslice", "string_slice", "[]string":
+ return ValueStringSlice
+ case "string", "":
+ return ValueString
+ default:
+ fmt.Fprintf(os.Stderr, "[discovery] flag type %q not recognised; defaulting to string\n", typeName)
+ return ValueString
+ }
+}
+
+// runtimeDefaultResolvers returns the edition-provided resolver map plus
+// built-in fallbacks for $now / $today (which are trivially local). Overlays
+// are expected to register the user-identity placeholders; $now / $today are
+// always available.
+func runtimeDefaultResolvers() map[string]edition.RuntimeDefaultFn {
+ resolvers := make(map[string]edition.RuntimeDefaultFn, len(runtimeDefaultWhitelist))
+ resolvers["$now"] = func(ctx context.Context) (string, bool) {
+ return fmt.Sprintf("%d", time.Now().UnixMilli()), true
+ }
+ resolvers["$today"] = func(ctx context.Context) (string, bool) {
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+ return time.Now().In(loc).Format("2006-01-02"), true
+ }
+ if hooks := edition.Get(); hooks != nil && hooks.RuntimeDefaults != nil {
+ for id, fn := range hooks.RuntimeDefaults() {
+ if fn != nil {
+ resolvers[id] = fn
+ }
+ }
+ }
+ return resolvers
+}
+
+// applyOmitWhen drops a key from params when its value meets the omit
+// condition for the declared mode. "empty" (the default) is handled by
+// CollectBindings upstream, so this function only processes "zero" and
+// "never" — "never" is a marker that keeps the zero value explicit, so we
+// do nothing for it.
+func applyOmitWhen(params map[string]any, key, mode string) {
+ switch strings.ToLower(strings.TrimSpace(mode)) {
+ case "never":
+ return
+ case "zero":
+ val, exists := params[key]
+ if !exists {
+ return
+ }
+ if isZeroValue(val) {
+ delete(params, key)
+ }
+ default:
+ // "empty" is the default, no-op.
+ }
+ // Emit a trace for anyone debugging envelope behaviour; kept at Debug so
+ // it never leaks into the default CLI output.
+ slog.Debug("applyOmitWhen", "key", key, "mode", mode)
+}
+
+func isZeroValue(v any) bool {
+ switch val := v.(type) {
+ case nil:
+ return true
+ case string:
+ return strings.TrimSpace(val) == ""
+ case bool:
+ return !val
+ case int:
+ return val == 0
+ case int64:
+ return val == 0
+ case float64:
+ return val == 0
+ case []any:
+ return len(val) == 0
+ case []string:
+ return len(val) == 0
+ case map[string]any:
+ return len(val) == 0
+ default:
+ return false
+ }
+}
+
// nestDottedPaths converts flat dotted keys in params into nested maps.
// Example: params["Body.query"] = "test" → params["Body"] = map{"query": "test"}
// If multiple dotted keys share a prefix, they are merged into the same nested map.
diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go
index 68f06cc2..1bd06621 100644
--- a/internal/compat/dynamic_commands_test.go
+++ b/internal/compat/dynamic_commands_test.go
@@ -14,12 +14,40 @@
package compat
import (
+ "context"
+ "strings"
"testing"
+ "github.com/spf13/cobra"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
+// captureRunner records the most recent invocation for assertion.
+type captureRunner struct {
+ lastProduct string
+ lastTool string
+ lastParams map[string]any
+}
+
+func (c *captureRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
+ c.lastProduct = inv.CanonicalProduct
+ c.lastTool = inv.Tool
+ c.lastParams = inv.Params
+ return executor.Result{Invocation: inv}, nil
+}
+
+// findChild returns the direct sub-command with the given name, or nil.
+func findChild(parent *cobra.Command, name string) *cobra.Command {
+ for _, c := range parent.Commands() {
+ if c.Name() == name {
+ return c
+ }
+ }
+ return nil
+}
+
func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
t.Parallel()
@@ -111,6 +139,1254 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
}
}
+// Phase 0 P1 schema extensions -----------------------------------------------
+
+// TestBuildDynamicCommands_ShorthandFlag verifies the Shorthand field wires
+// through to cobra's StringP short form.
+func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "conversationId": {
+ Alias: "conv",
+ Shorthand: "c",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ if len(cmds) != 1 {
+ t.Fatalf("expected 1 cmd, got %d", len(cmds))
+ }
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ f := send.Flags().Lookup("conv")
+ if f == nil {
+ t.Fatal("--conv flag missing")
+ }
+ if f.Shorthand != "c" {
+ t.Fatalf("expected shorthand 'c', got %q", f.Shorthand)
+ }
+}
+
+// TestBuildDynamicCommands_RequiredFlag verifies Required marks the flag via
+// cobra.MarkFlagRequired (recorded under the BashCompletion annotation).
+func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "conversationId": {
+ Alias: "conv",
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ f := send.Flags().Lookup("conv")
+ if f == nil {
+ t.Fatal("--conv flag missing")
+ }
+ if _, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
+ t.Fatalf("expected --conv to be marked required, annotations=%v", f.Annotations)
+ }
+}
+
+// TestBuildDynamicCommands_RequiredIgnoredWhenPositional verifies that
+// Required is ignored when Positional is true (cobra arity handles it).
+func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "text": {
+ Positional: true,
+ PositionalIndex: 0,
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ if f := send.Flags().Lookup("text"); f != nil {
+ t.Fatalf("positional 'text' must not be registered as flag: %+v", f)
+ }
+}
+
+// TestBuildDynamicCommands_PositionalArg verifies Positional params are NOT
+// registered as flags and that cobra Args switches to MinimumNArgs.
+func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "text": {
+ Positional: true,
+ PositionalIndex: 0,
+ },
+ "conversationId": {
+ Alias: "conv",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ if f := send.Flags().Lookup("text"); f != nil {
+ t.Fatalf("text should be positional, not flag, but got flag with usage=%q", f.Usage)
+ }
+ // Non-positional flag should still be present.
+ if f := send.Flags().Lookup("conv"); f == nil {
+ t.Fatal("--conv flag should still be registered")
+ }
+ // Validate arity: executing with zero args must fail with cobra's arity error.
+ // Execute() walks up to the root, so set args on the root command.
+ cmds[0].SetArgs([]string{"send"})
+ cmds[0].SilenceUsage = true
+ cmds[0].SilenceErrors = true
+ send.SilenceUsage = true
+ send.SilenceErrors = true
+ err := cmds[0].Execute()
+ if err == nil {
+ t.Fatal("expected error when required positional is missing")
+ }
+ if !strings.Contains(err.Error(), "arg") {
+ t.Fatalf("expected arity error, got %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_PositionalArgInjection verifies that positional
+// args are injected into params[property] when the leaf is invoked.
+func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
+ t.Parallel()
+
+ captured := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "text": {
+ Positional: true,
+ PositionalIndex: 0,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, captured, nil)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ if err := send.RunE(send, []string{"hello world"}); err != nil {
+ t.Fatalf("runE: %v", err)
+ }
+ if captured.lastParams["text"] != "hello world" {
+ t.Fatalf("expected params[text]='hello world', got %+v", captured.lastParams)
+ }
+}
+
+// TestBuildDynamicCommands_PositionalWithFlagAliases verifies that an
+// envelope binding marked Positional + Alias + Aliases registers visible
+// primary + hidden alias flags AND still accepts the value as a positional
+// arg. Mirrors the dws devdoc article search shape:
+//
+// { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
+func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
+ t.Parallel()
+
+ build := func() (*cobra.Command, *captureRunner) {
+ captured := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-devdoc",
+ CLI: market.CLIOverlay{
+ ID: "devdoc",
+ Command: "devdoc",
+ Groups: map[string]market.CLIGroupDef{
+ "article": {Description: "文档文章"},
+ },
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_open_platform_docs": {
+ CLIName: "search",
+ Group: "article",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ Aliases: []string{"keyword"},
+ Required: true,
+ Positional: true,
+ PositionalIndex: 0,
+ Description: "搜索关键词 (必填)",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ cmds := BuildDynamicCommands(servers, captured, nil)
+ article := findChild(cmds[0], "article")
+ if article == nil {
+ t.Fatal("article group not found")
+ }
+ search := findChild(article, "search")
+ if search == nil {
+ t.Fatal("search leaf not found")
+ }
+ return search, captured
+ }
+
+ t.Run("flag registration: visible query + hidden keyword", func(t *testing.T) {
+ t.Parallel()
+ search, _ := build()
+ queryFlag := search.Flags().Lookup("query")
+ if queryFlag == nil {
+ t.Fatal("--query flag should be registered for dual-mode positional")
+ }
+ if queryFlag.Hidden {
+ t.Fatal("--query flag should be visible (not hidden)")
+ }
+ keywordFlag := search.Flags().Lookup("keyword")
+ if keywordFlag == nil {
+ t.Fatal("--keyword hidden alias flag should be registered")
+ }
+ if !keywordFlag.Hidden {
+ t.Fatal("--keyword alias flag should be hidden")
+ }
+ })
+
+ t.Run("invocation via positional", func(t *testing.T) {
+ t.Parallel()
+ search, captured := build()
+ if err := search.RunE(search, []string{"MCP"}); err != nil {
+ t.Fatalf("RunE positional: %v", err)
+ }
+ if captured.lastParams["keyword"] != "MCP" {
+ t.Fatalf("positional: keyword = %v, want MCP", captured.lastParams["keyword"])
+ }
+ })
+
+ t.Run("invocation via --query primary flag", func(t *testing.T) {
+ t.Parallel()
+ search, captured := build()
+ if err := search.Flags().Set("query", "MCP"); err != nil {
+ t.Fatalf("Set --query: %v", err)
+ }
+ if err := search.RunE(search, nil); err != nil {
+ t.Fatalf("RunE --query: %v", err)
+ }
+ if captured.lastParams["keyword"] != "MCP" {
+ t.Fatalf("--query: keyword = %v, want MCP", captured.lastParams["keyword"])
+ }
+ })
+
+ t.Run("invocation via --keyword hidden alias", func(t *testing.T) {
+ t.Parallel()
+ search, captured := build()
+ if err := search.Flags().Set("keyword", "MCP"); err != nil {
+ t.Fatalf("Set --keyword: %v", err)
+ }
+ if err := search.RunE(search, nil); err != nil {
+ t.Fatalf("RunE --keyword: %v", err)
+ }
+ if captured.lastParams["keyword"] != "MCP" {
+ t.Fatalf("--keyword: keyword = %v, want MCP", captured.lastParams["keyword"])
+ }
+ })
+
+ t.Run("flag wins over positional when both supplied", func(t *testing.T) {
+ t.Parallel()
+ search, captured := build()
+ if err := search.Flags().Set("query", "FROM_FLAG"); err != nil {
+ t.Fatalf("Set --query: %v", err)
+ }
+ if err := search.RunE(search, []string{"FROM_POSITIONAL"}); err != nil {
+ t.Fatalf("RunE both: %v", err)
+ }
+ if captured.lastParams["keyword"] != "FROM_FLAG" {
+ t.Fatalf("flag should win: keyword = %v, want FROM_FLAG", captured.lastParams["keyword"])
+ }
+ })
+
+ t.Run("missing input returns validation error", func(t *testing.T) {
+ t.Parallel()
+ search, _ := build()
+ err := search.RunE(search, nil)
+ if err == nil {
+ t.Fatal("expected validation error when neither flag nor positional was supplied")
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "--query") || !strings.Contains(msg, "keyword") {
+ t.Fatalf("error message %q should reference both --query and keyword", msg)
+ }
+ })
+
+ t.Run("arity allows zero args (relaxed for dual-mode)", func(t *testing.T) {
+ t.Parallel()
+ search, _ := build()
+ // Args validator should accept zero args because --query / --keyword
+ // can satisfy the requirement; the validation step in RunE catches
+ // the truly-missing case.
+ if err := search.Args(search, []string{}); err != nil {
+ t.Fatalf("Args([]) should not error for dual-mode positional: %v", err)
+ }
+ if err := search.Args(search, []string{"MCP"}); err != nil {
+ t.Fatalf("Args([MCP]) should not error: %v", err)
+ }
+ if err := search.Args(search, []string{"MCP", "extra"}); err == nil {
+ t.Fatal("Args should cap at totalMax=1, extra arg should error")
+ }
+ })
+}
+
+// TestBuildDynamicCommands_PositionalArityMixed verifies that mixing pure
+// positional (required) with dual-mode positional (with flag aliases) yields
+// a RangeArgs validator: pure positional enforces the minimum, dual-mode
+// extends the maximum.
+func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-mixed",
+ CLI: market.CLIOverlay{
+ ID: "mixed",
+ Command: "mixed",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "do_thing": {
+ CLIName: "do",
+ Flags: map[string]market.CLIFlagOverride{
+ // pure positional, required: enforces MinimumNArgs(1)
+ "target": {
+ Positional: true,
+ PositionalIndex: 0,
+ Required: true,
+ },
+ // dual-mode positional at slot 1: extends totalMax to 2
+ "label": {
+ Alias: "label",
+ Aliases: []string{"name"},
+ Positional: true,
+ PositionalIndex: 1,
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ leaf := findChild(cmds[0], "do")
+ if leaf == nil {
+ t.Fatal("do leaf not found")
+ }
+
+ // 0 args → arity error (pure positional 'target' missing)
+ if err := leaf.Args(leaf, []string{}); err == nil {
+ t.Fatal("Args([]) should error: pure positional 'target' is required")
+ }
+ // 1 arg → fills target, label can come from --label flag
+ if err := leaf.Args(leaf, []string{"T"}); err != nil {
+ t.Fatalf("Args([T]) should succeed: %v", err)
+ }
+ // 2 args → both positionals satisfied
+ if err := leaf.Args(leaf, []string{"T", "L"}); err != nil {
+ t.Fatalf("Args([T, L]) should succeed: %v", err)
+ }
+ // 3 args → exceeds totalMax=2
+ if err := leaf.Args(leaf, []string{"T", "L", "extra"}); err == nil {
+ t.Fatal("Args([T, L, extra]) should error: totalMax=2")
+ }
+
+ // 1 arg + --label flag should satisfy the validation step in RunE.
+ if err := leaf.Flags().Set("label", "L_FROM_FLAG"); err != nil {
+ t.Fatalf("Set --label: %v", err)
+ }
+ if err := leaf.RunE(leaf, []string{"T"}); err != nil {
+ t.Fatalf("RunE([T]) with --label flag: %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_ServerOverride verifies ServerOverride routes
+// the tool invocation's CanonicalProduct to a different product.
+func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
+ t.Parallel()
+
+ captured := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_bots": {
+ CLIName: "bot-list",
+ ServerOverride: "bot",
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, captured, nil)
+ leaf := findChild(cmds[0], "bot-list")
+ if leaf == nil {
+ t.Fatal("bot-list leaf not found")
+ }
+ if err := leaf.RunE(leaf, nil); err != nil {
+ t.Fatalf("runE: %v", err)
+ }
+ if captured.lastProduct != "bot" {
+ t.Fatalf("expected CanonicalProduct=bot, got %q", captured.lastProduct)
+ }
+ if captured.lastTool != "list_bots" {
+ t.Fatalf("expected tool=list_bots, got %q", captured.lastTool)
+ }
+}
+
+// TestBuildDynamicCommands_ServerOverrideFallback verifies ServerOverride
+// falls back to cli.ID when left empty (backwards compat with existing configs).
+func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
+ t.Parallel()
+
+ captured := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_conversations": {CLIName: "list"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, captured, nil)
+ leaf := findChild(cmds[0], "list")
+ if leaf == nil {
+ t.Fatal("list leaf not found")
+ }
+ if err := leaf.RunE(leaf, nil); err != nil {
+ t.Fatalf("runE: %v", err)
+ }
+ if captured.lastProduct != "chat" {
+ t.Fatalf("expected CanonicalProduct=chat (fallback), got %q", captured.lastProduct)
+ }
+}
+
+// TestBuildDynamicCommands_DescriptionOverridesUsage verifies that overlay
+// Description wins over the default paramName usage text.
+func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "conversationId": {
+ Alias: "conv",
+ Description: "Target conversation open ID",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ f := send.Flags().Lookup("conv")
+ if f == nil {
+ t.Fatal("--conv flag missing")
+ }
+ if f.Usage != "Target conversation open ID" {
+ t.Fatalf("expected custom description, got %q", f.Usage)
+ }
+}
+
+// TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema verifies that when
+// the Detail API schema and overlay both define the same param, the overlay's
+// Description survives enrichment.
+func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message": {
+ CLIName: "send",
+ Flags: map[string]market.CLIFlagOverride{
+ "conversationId": {
+ Alias: "conv",
+ Description: "Overlay wins",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ details := map[string][]market.DetailTool{
+ "chat": {
+ {
+ ToolName: "send_message",
+ ToolRequest: `{"properties":{` +
+ `"conversationId":{"type":"string","description":"Schema description"}` +
+ `},"required":["conversationId"]}`,
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
+ send := findChild(cmds[0], "send")
+ if send == nil {
+ t.Fatal("send leaf not found")
+ }
+ f := send.Flags().Lookup("conv")
+ if f == nil {
+ t.Fatal("--conv flag missing")
+ }
+ if f.Usage != "Overlay wins" {
+ t.Fatalf("expected overlay description to win, got %q", f.Usage)
+ }
+}
+
+// Phase 5 P2 schema extensions ----------------------------------------------
+
+// TestBuildDynamicCommands_BodyWrapper verifies that bodyWrapper wraps all
+// user-facing params under the named key while keeping internal control
+// keys (prefix '_') at the top level.
+func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-todo",
+ CLI: market.CLIOverlay{
+ ID: "todo",
+ Command: "todo",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "create_todo": {
+ CLIName: "create",
+ BodyWrapper: "PersonalTodoCreateVO",
+ Flags: map[string]market.CLIFlagOverride{
+ "subject": {Alias: "subject", Required: true},
+ "dueTime": {Alias: "due", Transform: "iso8601_to_millis"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ create := findChild(cmds[0], "create")
+ if create == nil {
+ t.Fatal("create leaf not found")
+ }
+
+ cmds[0].SetArgs([]string{"create", "--subject", "buy milk", "--due", "2026-05-01"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+
+ wrap, ok := runner.lastParams["PersonalTodoCreateVO"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected params wrapped under PersonalTodoCreateVO, got %+v", runner.lastParams)
+ }
+ if wrap["subject"] != "buy milk" {
+ t.Fatalf("wrap[subject]=%v, want 'buy milk'", wrap["subject"])
+ }
+ if due, ok := wrap["dueTime"].(int64); !ok || due == 0 {
+ t.Fatalf("wrap[dueTime] must be int64 millis (iso8601_to_millis), got %T %v", wrap["dueTime"], wrap["dueTime"])
+ }
+ if _, exists := runner.lastParams["subject"]; exists {
+ t.Fatalf("bodyWrapper must move 'subject' off the top level, got %+v", runner.lastParams)
+ }
+}
+
+// TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys verifies that
+// keys starting with '_' stay at the top level so sensitive / _blocked /
+// _yes style confirmation plumbing keeps working.
+func TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys(t *testing.T) {
+ t.Parallel()
+
+ params := map[string]any{
+ "subject": "x",
+ "_blocked": true,
+ "_yes": false,
+ }
+ wrapParamsIntoBody(params, "Body")
+ body, ok := params["Body"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected Body wrapper, got %+v", params)
+ }
+ if body["subject"] != "x" {
+ t.Fatalf("body[subject]=%v, want 'x'", body["subject"])
+ }
+ if _, has := body["_blocked"]; has {
+ t.Fatal("internal _blocked must not be moved into body")
+ }
+ if _, has := params["_blocked"]; !has {
+ t.Fatal("_blocked must remain at top level")
+ }
+}
+
+// TestBuildDynamicCommands_MutuallyExclusive verifies that cobra refuses to
+// run when two flags from a mutually-exclusive group are set together.
+func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_messages": {
+ CLIName: "list",
+ Flags: map[string]market.CLIFlagOverride{
+ "groupId": {Alias: "group"},
+ "userId": {Alias: "user"},
+ "openDingtalkId": {Alias: "open-dingtalk-id"},
+ },
+ MutuallyExclusive: [][]string{
+ {"group", "user", "open-dingtalk-id"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ err := cmds[0].Execute()
+ if err == nil {
+ t.Fatal("expected mutually-exclusive error, got nil")
+ }
+ // cobra 1.10+ prints "if any flags in the group [...] are set none of the others can be";
+ // earlier versions used "mutually exclusive". Accept either wording.
+ msg := err.Error()
+ if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
+ t.Fatalf("expected mutually-exclusive error, got %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_RequireOneOf verifies that cobra refuses to run
+// when none of the required-one-of flags are set.
+func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_messages": {
+ CLIName: "list",
+ Flags: map[string]market.CLIFlagOverride{
+ "groupId": {Alias: "group"},
+ "userId": {Alias: "user"},
+ },
+ RequireOneOf: [][]string{
+ {"group", "user"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ cmds[0].SetArgs([]string{"list"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ err := cmds[0].Execute()
+ if err == nil {
+ t.Fatal("expected require-one-of error, got nil")
+ }
+ if !strings.Contains(err.Error(), "required") && !strings.Contains(err.Error(), "one") {
+ t.Fatalf("expected required-one-of error, got %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_RequireOneOfSatisfied verifies that setting one
+// of the required flags lets the command run normally.
+func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_messages": {
+ CLIName: "list",
+ Flags: map[string]market.CLIFlagOverride{
+ "groupId": {Alias: "group"},
+ "userId": {Alias: "user"},
+ },
+ RequireOneOf: [][]string{{"group", "user"}},
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"list", "--group", "g1"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if runner.lastParams["groupId"] != "g1" {
+ t.Fatalf("expected groupId=g1, got %+v", runner.lastParams)
+ }
+}
+
+// TestBuildDynamicCommands_RedirectTo verifies redirectTo replaces the leaf
+// with a stub that only prints the redirect target.
+func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "old_history": {
+ CLIName: "history",
+ RedirectTo: "dws chat message list",
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ history := findChild(cmds[0], "history")
+ if history == nil {
+ t.Fatal("history stub not found")
+ }
+ out := &strings.Builder{}
+ history.SetOut(out)
+ history.SetErr(out)
+ if err := history.RunE(history, nil); err != nil {
+ t.Fatalf("runE: %v", err)
+ }
+ if !strings.Contains(out.String(), "dws chat message list") {
+ t.Fatalf("redirect output missing target, got %q", out.String())
+ }
+ if runner.lastTool != "" {
+ t.Fatalf("redirect must not call a tool, got %q", runner.lastTool)
+ }
+ // Redirect stubs disable flag parsing, so arbitrary args must not error.
+ if history.Flags().Lookup("json") != nil {
+ t.Fatal("redirect stub must not register --json / --params flags")
+ }
+}
+
+// TestBuildDynamicCommands_Hints verifies cli.hintCommands creates a stub
+// sub-command under the overlay root (or under a named group) that prints
+// the canonical path instead of invoking a tool.
+func TestBuildDynamicCommands_Hints(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ Groups: map[string]market.CLIGroupDef{
+ "message": {Description: "会话消息"},
+ },
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_conversations": {CLIName: "list", Group: "message"},
+ },
+ Hints: map[string]market.CLIHintDef{
+ "history": {
+ Target: "dws chat message list",
+ Description: "migrated to `message list`",
+ },
+ "purge": {
+ Target: "dws chat message delete-all",
+ Group: "message",
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ root := cmds[0]
+
+ // history hint attached directly under root.
+ history := findChild(root, "history")
+ if history == nil {
+ t.Fatal("hint 'history' missing under chat root")
+ }
+ if history.Short != "migrated to `message list`" {
+ t.Fatalf("hint description not applied, got %q", history.Short)
+ }
+
+ // purge hint nested under message group.
+ msg := findChild(root, "message")
+ if msg == nil {
+ t.Fatal("message group missing")
+ }
+ purge := findChild(msg, "purge")
+ if purge == nil {
+ t.Fatal("hint 'purge' missing under message group")
+ }
+
+ out := &strings.Builder{}
+ purge.SetOut(out)
+ if err := purge.RunE(purge, nil); err != nil {
+ t.Fatalf("runE: %v", err)
+ }
+ if !strings.Contains(out.String(), "dws chat message delete-all") {
+ t.Fatalf("hint output missing target, got %q", out.String())
+ }
+}
+
+// TestBuildDynamicCommands_UnknownFlagConstraintSkipped verifies that a
+// stale / malformed mutuallyExclusive referencing an unknown flag is logged
+// and skipped rather than blocking command tree construction.
+func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_messages": {
+ CLIName: "list",
+ Flags: map[string]market.CLIFlagOverride{
+ "groupId": {Alias: "group"},
+ },
+ MutuallyExclusive: [][]string{
+ {"group", "does-not-exist"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ list := findChild(cmds[0], "list")
+ if list == nil {
+ t.Fatal("list leaf not found (constraint validation must not abort build)")
+ }
+ cmds[0].SetArgs([]string{"list", "--group", "g1"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("command should run despite skipped constraint, got %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_PrimarySet verifies the primary
+// flag still works when a binding declares extra hidden aliases.
+func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_contact_by_key_word": {
+ CLIName: "search",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ Aliases: []string{"keyword"},
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"search", "--query", "hello"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if runner.lastParams["keyword"] != "hello" {
+ t.Fatalf("expected keyword=hello, got %+v", runner.lastParams)
+ }
+
+ // The hidden alias must exist on the leaf command but be marked hidden.
+ leaf := findChild(cmds[0], "search")
+ if leaf == nil {
+ t.Fatal("search leaf missing")
+ }
+ alias := leaf.Flags().Lookup("keyword")
+ if alias == nil {
+ t.Fatal("hidden alias --keyword not registered")
+ }
+ if !alias.Hidden {
+ t.Fatalf("--keyword must be hidden (got Hidden=false)")
+ }
+ if primary := leaf.Flags().Lookup("query"); primary == nil || primary.Hidden {
+ t.Fatalf("--query must be registered and visible (got %+v)", primary)
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet verifies that
+// passing only a hidden alias satisfies Required and routes the value to
+// params[Property]. Regression: pre-fix envelope rejected --keyword with
+// "unknown flag".
+func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_contact_by_key_word": {
+ CLIName: "search",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ Aliases: []string{"keyword"},
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute with hidden alias: %v", err)
+ }
+ if runner.lastParams["keyword"] != "hi" {
+ t.Fatalf("expected keyword=hi, got %+v", runner.lastParams)
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet verifies
+// the self-check fallback: when Required binding has aliases but none are
+// supplied, CollectBindings emits "-- is required".
+func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_contact_by_key_word": {
+ CLIName: "search",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ Aliases: []string{"keyword"},
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"search"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ err := cmds[0].Execute()
+ if err == nil {
+ t.Fatal("expected required error, got nil")
+ }
+ if !strings.Contains(err.Error(), "--query is required") {
+ t.Fatalf("expected --query is required, got %v", err)
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet verifies
+// that when both the primary and an alias are provided on the CLI, the
+// primary wins (matches cmdutil.FlagOrFallback precedence: primary first).
+func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_contact_by_key_word": {
+ CLIName: "search",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ Aliases: []string{"keyword"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if runner.lastParams["keyword"] != "primary" {
+ t.Fatalf("expected primary to win (keyword=primary), got %+v", runner.lastParams)
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_MultiAliasChain verifies a chain
+// of 3+ aliases resolves the value from the first one that is set.
+func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "get_user_info_by_user_ids": {
+ CLIName: "get",
+ Flags: map[string]market.CLIFlagOverride{
+ "user_id_list": {
+ Alias: "ids",
+ Aliases: []string{"user-id", "user-ids"},
+ Required: true,
+ Transform: "csv_to_array",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute with --user-ids: %v", err)
+ }
+ got, ok := runner.lastParams["user_id_list"].([]any)
+ if !ok {
+ t.Fatalf("expected []any for user_id_list after csv_to_array, got %T (%+v)", runner.lastParams["user_id_list"], runner.lastParams)
+ }
+ if len(got) != 2 || got[0] != "u1" || got[1] != "u2" {
+ t.Fatalf("expected [u1 u2], got %+v", got)
+ }
+
+ // All three hidden aliases must be registered and hidden, primary visible.
+ leaf := findChild(cmds[0], "get")
+ if leaf == nil {
+ t.Fatal("get leaf missing")
+ }
+ for _, name := range []string{"user-id", "user-ids"} {
+ f := leaf.Flags().Lookup(name)
+ if f == nil {
+ t.Fatalf("alias --%s not registered", name)
+ }
+ if !f.Hidden {
+ t.Fatalf("alias --%s must be hidden", name)
+ }
+ }
+ if p := leaf.Flags().Lookup("ids"); p == nil || p.Hidden {
+ t.Fatalf("primary --ids must be visible")
+ }
+}
+
+// TestBuildDynamicCommands_MultipleAliases_Dedup verifies that reserved
+// names ("json", "params"), duplicates of the primary, duplicates of the
+// single Alias, and intra-slice duplicates are all silently skipped so
+// cobra never double-registers a flag.
+func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-contact",
+ CLI: market.CLIOverlay{
+ ID: "contact",
+ Command: "contact",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "search_contact_by_key_word": {
+ CLIName: "search",
+ Flags: map[string]market.CLIFlagOverride{
+ "keyword": {
+ Alias: "query",
+ // Conflicts: query dup with alias, json/params are
+ // reserved, keyword appears twice.
+ Aliases: []string{"query", "json", "params", "keyword", "keyword"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ // If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
+ // should build and execute fine.
+ cmds := BuildDynamicCommands(servers, runner, nil)
+ cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if runner.lastParams["keyword"] != "ok" {
+ t.Fatalf("expected keyword=ok, got %+v", runner.lastParams)
+ }
+}
+
func TestBuildDynamicCommands_NoParent(t *testing.T) {
t.Parallel()
@@ -143,3 +1419,529 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
}
}
+
+// envelope-help-alignment ----------------------------------------------------
+//
+// The next four tests cover the envelope-driven --help upgrades so dws-wukong
+// can ship hardcoded-equivalent --help text purely through Diamond config.
+
+// TestBuildDynamicCommands_ExampleField verifies CLIToolOverride.Example flows
+// to cobra.Command.Example, surfacing the "Examples:" section in --help.
+func TestBuildDynamicCommands_ExampleField(t *testing.T) {
+ t.Parallel()
+
+ const want = " dws oa approval list-forms --cursor 0 --size 100"
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-oa",
+ CLI: market.CLIOverlay{
+ ID: "oa",
+ Command: "oa",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_user_visible_process": {
+ CLIName: "list-forms",
+ Group: "approval",
+ Example: " dws oa approval list-forms --cursor 0 --size 100",
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ approval := findChild(cmds[0], "approval")
+ if approval == nil {
+ t.Fatal("approval group not found")
+ }
+ leaf := findChild(approval, "list-forms")
+ if leaf == nil {
+ t.Fatal("list-forms leaf not found")
+ }
+ if leaf.Example != want {
+ t.Fatalf("Example mismatch:\n want: %q\n got: %q", want, leaf.Example)
+ }
+ if !strings.Contains(leaf.UsageString(), "Examples:") {
+ t.Fatalf("expected 'Examples:' section in --help; usage:\n%s", leaf.UsageString())
+ }
+}
+
+// TestApplyBindings_VisibleFlagDefault_String verifies that
+// CLIFlagOverride.Default flows to cobra String flags so --help shows
+// (default "0"). Hidden-only behavior is unchanged; this is the path that was
+// previously dead code for visible flags.
+func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-oa",
+ CLI: market.CLIOverlay{
+ ID: "oa",
+ Command: "oa",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_user_visible_process": {
+ CLIName: "list-forms",
+ Flags: map[string]market.CLIFlagOverride{
+ "cursor": {
+ Alias: "cursor",
+ Type: "string",
+ Default: "0",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ leaf := findChild(cmds[0], "list-forms")
+ if leaf == nil {
+ t.Fatal("list-forms leaf not found")
+ }
+ f := leaf.Flags().Lookup("cursor")
+ if f == nil {
+ t.Fatal("--cursor flag missing")
+ }
+ if f.DefValue != "0" {
+ t.Fatalf("expected DefValue=\"0\", got %q", f.DefValue)
+ }
+ usage := leaf.UsageString()
+ if !strings.Contains(usage, `(default "0")`) {
+ t.Fatalf("expected --help to contain (default \"0\"); got:\n%s", usage)
+ }
+}
+
+// TestApplyBindings_VisibleFlagDefault_Int verifies the Int kind path: cobra
+// renders int defaults without quotes, so we look for "(default 100)".
+func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-oa",
+ CLI: market.CLIOverlay{
+ ID: "oa",
+ Command: "oa",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_user_visible_process": {
+ CLIName: "list-forms",
+ Flags: map[string]market.CLIFlagOverride{
+ "pageSize": {
+ Alias: "size",
+ Type: "int",
+ Default: "100",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ leaf := findChild(cmds[0], "list-forms")
+ if leaf == nil {
+ t.Fatal("list-forms leaf not found")
+ }
+ f := leaf.Flags().Lookup("size")
+ if f == nil {
+ t.Fatal("--size flag missing")
+ }
+ if f.DefValue != "100" {
+ t.Fatalf("expected DefValue=\"100\", got %q", f.DefValue)
+ }
+ if !strings.Contains(leaf.UsageString(), "(default 100)") {
+ t.Fatalf("expected --help to contain (default 100); got:\n%s", leaf.UsageString())
+ }
+}
+
+// TestCollectBindings_DefaultDoesNotImplyChanged guards the lower half of
+// the v3.2 contract: CollectBindings itself must not infer "user changed
+// this flag" from the presence of an envelope Default. Default-driven MCP
+// body injection lives in buildOverrideBindings' normalizer (verified by the
+// TestNormalizer_* tests below) so user-vs-default attribution stays clean
+// — `firstChangedFlag` is the single source of truth for "user typed it".
+func TestCollectBindings_DefaultDoesNotImplyChanged(t *testing.T) {
+ t.Parallel()
+
+ bindings := []FlagBinding{
+ {
+ FlagName: "cursor",
+ Property: "cursor",
+ Kind: ValueString,
+ Default: "0",
+ Usage: "page cursor",
+ },
+ }
+ cmd := &cobra.Command{Use: "list-forms"}
+ ApplyBindings(cmd, bindings)
+
+ // Sanity: cobra received the default and would render it in --help.
+ if got := cmd.Flags().Lookup("cursor").DefValue; got != "0" {
+ t.Fatalf("expected DefValue=\"0\" wired to cobra, got %q", got)
+ }
+
+ // Don't call cmd.SetArgs / cmd.Execute — i.e. user typed nothing.
+ params, err := CollectBindings(cmd, bindings, nil)
+ if err != nil {
+ t.Fatalf("CollectBindings returned error: %v", err)
+ }
+ if _, ok := params["cursor"]; ok {
+ t.Fatalf("default-only flag must not appear in MCP params; got %v", params)
+ }
+}
+
+// TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted is the v3.2 mirror of
+// the test above: CollectBindings still skips, but the normalizer returned
+// by buildOverrideBindings must inject the envelope default for visible
+// flags so the MCP body matches the hardcoded helper command's behavior
+// (`mustGetFlag(cobra default) → body`). This is the regression that broke
+// `dws oa approval list-forms` when the user omitted --cursor / --size.
+func TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted(t *testing.T) {
+ t.Parallel()
+
+ override := market.CLIToolOverride{
+ CLIName: "list-forms",
+ Flags: map[string]market.CLIFlagOverride{
+ "cursor": {
+ Alias: "cursor",
+ Type: "int",
+ Default: "0",
+ },
+ "pageSize": {
+ Alias: "size",
+ Type: "int",
+ Default: "100",
+ },
+ },
+ }
+
+ bindings, normalizer := buildOverrideBindings(override)
+ if normalizer == nil {
+ t.Fatal("expected non-nil normalizer when defaults are present")
+ }
+ cmd := &cobra.Command{Use: "list-forms"}
+ ApplyBindings(cmd, bindings)
+
+ params, err := CollectBindings(cmd, bindings, nil)
+ if err != nil {
+ t.Fatalf("CollectBindings returned error: %v", err)
+ }
+ if err := normalizer(cmd, params); err != nil {
+ t.Fatalf("normalizer returned error: %v", err)
+ }
+
+ if got, ok := params["cursor"].(int); !ok || got != 0 {
+ t.Fatalf("expected params[cursor] = int(0), got %T(%v)", params["cursor"], params["cursor"])
+ }
+ if got, ok := params["pageSize"].(int); !ok || got != 100 {
+ t.Fatalf("expected params[pageSize] = int(100), got %T(%v)", params["pageSize"], params["pageSize"])
+ }
+}
+
+// TestNormalizer_DefaultCoercedByKind covers every ValueKind the envelope
+// type-name dictionary (kindFromTypeName) can actually produce today —
+// string / int / bool / string_slice — so a number-typed schema receives a
+// number (not the raw envelope string) and slice kinds get a []string.
+// Catches regressions in the parseFlagDefault dispatch.
+//
+// ValueFloat and the *_slice numeric kinds aren't reachable from envelope
+// `type` strings yet, but the normalizer still handles them for forward
+// compatibility (asserted indirectly by the build).
+func TestNormalizer_DefaultCoercedByKind(t *testing.T) {
+ t.Parallel()
+
+ override := market.CLIToolOverride{
+ CLIName: "demo",
+ Flags: map[string]market.CLIFlagOverride{
+ "name": {Alias: "name", Type: "string", Default: "alice"},
+ "page": {Alias: "page", Type: "int", Default: "7"},
+ "verbose": {Alias: "verbose", Type: "bool", Default: "true"},
+ "tags": {Alias: "tags", Type: "string_slice", Default: "a,b,c"},
+ },
+ }
+
+ bindings, normalizer := buildOverrideBindings(override)
+ if normalizer == nil {
+ t.Fatal("expected non-nil normalizer when defaults are present")
+ }
+ cmd := &cobra.Command{Use: "demo"}
+ ApplyBindings(cmd, bindings)
+
+ params, err := CollectBindings(cmd, bindings, nil)
+ if err != nil {
+ t.Fatalf("CollectBindings returned error: %v", err)
+ }
+ if err := normalizer(cmd, params); err != nil {
+ t.Fatalf("normalizer returned error: %v", err)
+ }
+
+ if got, ok := params["name"].(string); !ok || got != "alice" {
+ t.Fatalf("name: expected string \"alice\", got %T(%v)", params["name"], params["name"])
+ }
+ if got, ok := params["page"].(int); !ok || got != 7 {
+ t.Fatalf("page: expected int 7, got %T(%v)", params["page"], params["page"])
+ }
+ if got, ok := params["verbose"].(bool); !ok || !got {
+ t.Fatalf("verbose: expected bool true, got %T(%v)", params["verbose"], params["verbose"])
+ }
+ got, ok := params["tags"].([]string)
+ if !ok {
+ t.Fatalf("tags: expected []string, got %T(%v)", params["tags"], params["tags"])
+ }
+ if strings.Join(got, ",") != "a,b,c" {
+ t.Fatalf("tags: expected [a b c], got %v", got)
+ }
+}
+
+// TestNormalizer_DefaultDoesNotOverrideUserValue locks in the
+// `if exists continue` guard so user input always beats the envelope default,
+// matching CollectBindings' user-changed-flag wins contract.
+func TestNormalizer_DefaultDoesNotOverrideUserValue(t *testing.T) {
+ t.Parallel()
+
+ override := market.CLIToolOverride{
+ CLIName: "list-forms",
+ Flags: map[string]market.CLIFlagOverride{
+ "cursor": {
+ Alias: "cursor",
+ Type: "int",
+ Default: "0",
+ },
+ },
+ }
+
+ bindings, normalizer := buildOverrideBindings(override)
+ if normalizer == nil {
+ t.Fatal("expected non-nil normalizer when defaults are present")
+ }
+ cmd := &cobra.Command{Use: "list-forms"}
+ ApplyBindings(cmd, bindings)
+
+ if err := cmd.ParseFlags([]string{"--cursor", "5"}); err != nil {
+ t.Fatalf("ParseFlags returned error: %v", err)
+ }
+
+ params, err := CollectBindings(cmd, bindings, nil)
+ if err != nil {
+ t.Fatalf("CollectBindings returned error: %v", err)
+ }
+ if err := normalizer(cmd, params); err != nil {
+ t.Fatalf("normalizer returned error: %v", err)
+ }
+
+ if got, ok := params["cursor"].(int); !ok || got != 5 {
+ t.Fatalf("expected params[cursor] = int(5), got %T(%v)", params["cursor"], params["cursor"])
+ }
+}
+
+// parent-merge tests ---------------------------------------------------------
+
+// TestBuildDynamicCommands_ParentMergeSameName covers the case where two
+// servers share the same cli.command + cli.parent. Instead of producing two
+// sibling subcommands with the same Name under the parent (which cobra allows
+// but `--help` renders as duplicate rows), the compat layer merges them into
+// a single subcommand whose children are the union of both sides.
+func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "group-chat",
+ Command: "chat",
+ Groups: map[string]market.CLIGroupDef{
+ "message": {Description: "会话消息管理"},
+ },
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message_as_user": {CLIName: "send", Group: "message"},
+ "list_conversation_message_v2": {CLIName: "list", Group: "message"},
+ },
+ },
+ },
+ {
+ // Second server contributes more leaves into the same "message"
+ // namespace via command="message" + parent="chat".
+ Endpoint: "https://endpoint-bot",
+ CLI: market.CLIOverlay{
+ ID: "bot-message",
+ Command: "message",
+ Parent: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_robot_group_message": {CLIName: "send-by-bot"},
+ "send_message_by_custom_robot": {CLIName: "send-by-webhook"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ if len(cmds) != 1 || cmds[0].Name() != "chat" {
+ t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
+ }
+
+ // There must be exactly one child named "message" under chat, not two.
+ var messageCmds []*cobra.Command
+ for _, sub := range cmds[0].Commands() {
+ if sub.Name() == "message" {
+ messageCmds = append(messageCmds, sub)
+ }
+ }
+ if len(messageCmds) != 1 {
+ names := make([]string, 0, len(cmds[0].Commands()))
+ for _, c := range cmds[0].Commands() {
+ names = append(names, c.Name())
+ }
+ t.Fatalf("expected exactly one 'message' under chat, got %d — chat children: %v", len(messageCmds), names)
+ }
+
+ // The merged message subcommand must contain all four leaves.
+ want := map[string]bool{"send": false, "list": false, "send-by-bot": false, "send-by-webhook": false}
+ for _, leaf := range messageCmds[0].Commands() {
+ if _, ok := want[leaf.Name()]; ok {
+ want[leaf.Name()] = true
+ }
+ }
+ for leaf, seen := range want {
+ if !seen {
+ t.Errorf("expected 'chat message %s' after merge, missing", leaf)
+ }
+ }
+}
+
+// TestBuildDynamicCommands_ParentMergeRecursive covers a multi-level merge:
+// chat already has `group.members` (with add/remove), and a separate
+// bot-group server contributes `dws chat group members add-bot` via
+// command=group + parent=chat + groups.members. The "group" and "members"
+// nodes must each be merged, not duplicated.
+func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "group-chat",
+ Command: "chat",
+ Groups: map[string]market.CLIGroupDef{
+ "group": {Description: "群组管理"},
+ "group.members": {Description: "群成员管理"},
+ },
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "add_group_member": {CLIName: "add", Group: "group.members"},
+ "remove_group_member": {CLIName: "remove", Group: "group.members"},
+ },
+ },
+ },
+ {
+ Endpoint: "https://endpoint-bot",
+ CLI: market.CLIOverlay{
+ ID: "bot-group",
+ Command: "group",
+ Parent: "chat",
+ Groups: map[string]market.CLIGroupDef{
+ "members": {Description: "机器人群成员"},
+ },
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "add_robot_to_group": {CLIName: "add-bot", Group: "members"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ if len(cmds) != 1 || cmds[0].Name() != "chat" {
+ t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
+ }
+
+ // One 'group' under chat.
+ var groupCmds []*cobra.Command
+ for _, sub := range cmds[0].Commands() {
+ if sub.Name() == "group" {
+ groupCmds = append(groupCmds, sub)
+ }
+ }
+ if len(groupCmds) != 1 {
+ t.Fatalf("expected single 'group' under chat, got %d", len(groupCmds))
+ }
+
+ // One 'members' under chat.group.
+ var membersCmds []*cobra.Command
+ for _, sub := range groupCmds[0].Commands() {
+ if sub.Name() == "members" {
+ membersCmds = append(membersCmds, sub)
+ }
+ }
+ if len(membersCmds) != 1 {
+ t.Fatalf("expected single 'members' under chat.group, got %d", len(membersCmds))
+ }
+
+ // The merged members subcommand must contain add, remove, add-bot.
+ want := map[string]bool{"add": false, "remove": false, "add-bot": false}
+ for _, leaf := range membersCmds[0].Commands() {
+ if _, ok := want[leaf.Name()]; ok {
+ want[leaf.Name()] = true
+ }
+ }
+ for leaf, seen := range want {
+ if !seen {
+ t.Errorf("expected 'chat group members %s', missing", leaf)
+ }
+ }
+}
+
+// TestBuildDynamicCommands_ParentMergeLeafCollision verifies that when two
+// servers both produce the same leaf path (e.g. both try to register
+// `chat message send`), the first one wins and the second is silently
+// dropped rather than producing a duplicate cobra command.
+func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "group-chat",
+ Command: "chat",
+ Groups: map[string]market.CLIGroupDef{"message": {Description: "消息"}},
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_message_as_user": {CLIName: "send", Group: "message"},
+ },
+ },
+ },
+ {
+ Endpoint: "https://endpoint-bot",
+ CLI: market.CLIOverlay{
+ ID: "bot-message",
+ Command: "message",
+ Parent: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ // Intentional collision: same leaf name.
+ "send_robot_group_message": {CLIName: "send"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+ if len(cmds) != 1 {
+ t.Fatalf("expected 1 top-level, got %d", len(cmds))
+ }
+ messageCmd := findSubcommand(cmds[0], "message")
+ if messageCmd == nil {
+ t.Fatal("expected 'message' subcommand under chat")
+ }
+ // Exactly one 'send' leaf.
+ var sendCount int
+ for _, leaf := range messageCmd.Commands() {
+ if leaf.Name() == "send" {
+ sendCount++
+ }
+ }
+ if sendCount != 1 {
+ t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount)
+ }
+}
diff --git a/internal/compat/hint_redirect.go b/internal/compat/hint_redirect.go
new file mode 100644
index 00000000..12f3b8b0
--- /dev/null
+++ b/internal/compat/hint_redirect.go
@@ -0,0 +1,55 @@
+// 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 compat
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// buildOverlayRedirect builds a top-level product command whose only behaviour
+// is printing "Please use: dws " and returning nil. All args/flags are
+// accepted and ignored so users running the old command path get the redirect
+// message instead of a parse error.
+//
+// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo).
+func buildOverlayRedirect(name, description, target string) *cobra.Command {
+ target = strings.TrimSpace(target)
+ short := strings.TrimSpace(description)
+ if short == "" {
+ if target != "" {
+ short = fmt.Sprintf("moved → %s", target)
+ } else {
+ short = "command relocated; see --help for the canonical path"
+ }
+ }
+ cmd := &cobra.Command{
+ Use: name,
+ Short: short,
+ Long: fmt.Sprintf("This command has moved. Please use: dws %s", target),
+ DisableFlagParsing: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if target == "" {
+ _ = cmd.Help()
+ return nil
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target)
+ return nil
+ },
+ }
+ return cmd
+}
diff --git a/internal/compat/output_format.go b/internal/compat/output_format.go
new file mode 100644
index 00000000..91ebb101
--- /dev/null
+++ b/internal/compat/output_format.go
@@ -0,0 +1,107 @@
+// 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 compat
+
+import (
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
+)
+
+// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping
+// function applied to executor.Result.Response before the formatter runs.
+// See discovery-schema-v3 §2.5.
+//
+// Apply order (deterministic): drop → rename → columns.
+// Columns filter only takes effect under --format=table (the formatter
+// consults it via the "_columns" marker key this function injects).
+// Returns nil when the spec is empty so callers can skip wiring.
+func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any {
+ if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 {
+ return nil
+ }
+ dropped := append([]string(nil), spec.Drop...)
+ renamed := make(map[string]string, len(spec.Rename))
+ for k, v := range spec.Rename {
+ renamed[k] = v
+ }
+ columns := append([]string(nil), spec.Columns...)
+
+ return func(resp map[string]any) map[string]any {
+ if resp == nil {
+ return resp
+ }
+ applyDrop(resp, dropped)
+ applyRename(resp, renamed)
+ if len(columns) > 0 {
+ resp["_columns"] = append([]string(nil), columns...)
+ }
+ return resp
+ }
+}
+
+// applyDrop removes the named keys at the top level and one level of nested
+// object. Missing keys are silently ignored. Keys with "." are treated as a
+// two-part path (parent.child).
+func applyDrop(m map[string]any, keys []string) {
+ for _, key := range keys {
+ if key == "" {
+ continue
+ }
+ delete(m, key)
+ }
+ for _, v := range m {
+ if inner, ok := v.(map[string]any); ok {
+ for _, key := range keys {
+ if key == "" {
+ continue
+ }
+ delete(inner, key)
+ }
+ }
+ }
+}
+
+// applyRename moves fields from src key to dst key at top level and one level
+// of nested object. Collisions overwrite silently. Missing src keys are
+// no-ops.
+func applyRename(m map[string]any, mapping map[string]string) {
+ if len(mapping) == 0 {
+ return
+ }
+ // First pass: top level.
+ for src, dst := range mapping {
+ if src == "" || dst == "" || src == dst {
+ continue
+ }
+ if v, ok := m[src]; ok {
+ m[dst] = v
+ delete(m, src)
+ }
+ }
+ // Second pass: one level nested.
+ for _, v := range m {
+ inner, ok := v.(map[string]any)
+ if !ok {
+ continue
+ }
+ for src, dst := range mapping {
+ if src == "" || dst == "" || src == dst {
+ continue
+ }
+ if val, ok := inner[src]; ok {
+ inner[dst] = val
+ delete(inner, src)
+ }
+ }
+ }
+}
diff --git a/internal/compat/registry.go b/internal/compat/registry.go
index ccbd5b55..6b57fd15 100644
--- a/internal/compat/registry.go
+++ b/internal/compat/registry.go
@@ -56,11 +56,27 @@ type Target struct {
type FlagBinding struct {
FlagName string
Alias string
+ // Aliases are additional hidden flag names that map to the same MCP
+ // parameter. Any of them being set satisfies Required, and the value
+ // is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
+ // Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
+ Aliases []string
Short string
Property string
Kind ValueKind
Usage string
Required bool
+ // Default is the cobra-level flag default value as a string. Parsed
+ // into the Kind-appropriate primitive at registration time. Empty
+ // string keeps the existing zero-value default. This only affects
+ // what cobra renders in --help (the "(default ...)" suffix); it does
+ // NOT inject the value into MCP params on its own — CollectBindings
+ // still gates writes by user-changed flags via firstChangedFlag.
+ Default string
+ // Positional binds this parameter to a positional CLI argument rather
+ // than a --flag. PositionalIndex is the 0-based slot.
+ Positional bool
+ PositionalIndex int
}
type Normalizer func(cmd *cobra.Command, params map[string]any) error
@@ -75,6 +91,10 @@ type Route struct {
Target Target
Bindings []FlagBinding
Normalizer Normalizer
+ // OutputTransform, when non-nil, post-processes the MCP response payload
+ // (rename / drop / columns) before the formatter emits it. Wired up from
+ // CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5.
+ OutputTransform func(map[string]any) map[string]any
}
type CommandFactory func(runner executor.Runner) *cobra.Command
@@ -109,14 +129,87 @@ func NewFallbackCommands(runner executor.Runner) []*cobra.Command {
var NewGroupCommand = cobracmd.NewGroupCommand
func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
+ // Compute positional arity. Two counts:
+ // - totalMax: the highest PositionalIndex+1 across all positional bindings
+ // (caps how many trailing args cobra accepts).
+ // - strictMin: the highest PositionalIndex+1 among "pure" positional
+ // bindings (no flag aliases). Backward-compat: any pure positional
+ // binding implies required arity at parse time, regardless of Required.
+ //
+ // Dual-mode positional bindings (positional + envelope-declared flag
+ // aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`)
+ // are counted into totalMax but excluded from strictMin so a flag-only
+ // invocation parses; their required-presence is enforced by
+ // validateRequiredPositionalBindings inside RunE.
+ //
+ // For positional bindings, buildOverrideBindings populates FlagName /
+ // Aliases only when the envelope explicitly declared them, so the
+ // dual-mode detection here is unambiguous.
+ strictMin := 0
+ totalMax := 0
+ for _, b := range route.Bindings {
+ if !b.Positional {
+ continue
+ }
+ if b.PositionalIndex+1 > totalMax {
+ totalMax = b.PositionalIndex + 1
+ }
+ hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0
+ if !hasFlagAlias && b.PositionalIndex+1 > strictMin {
+ strictMin = b.PositionalIndex + 1
+ }
+ }
+ var argsValidator cobra.PositionalArgs = cobra.NoArgs
+ switch {
+ case totalMax == 0:
+ argsValidator = cobra.NoArgs
+ case strictMin > 0 && strictMin == totalMax:
+ argsValidator = cobra.MinimumNArgs(strictMin)
+ case strictMin > 0:
+ argsValidator = cobra.RangeArgs(strictMin, totalMax)
+ default:
+ argsValidator = cobra.MaximumNArgs(totalMax)
+ }
+
+ // Extend Use with [] tokens for positional bindings so
+ // `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded
+ // helper commands' style (e.g. devdoc article search [keyword]).
+ use := route.Use
+ if totalMax > 0 {
+ ordered := make([]FlagBinding, 0, totalMax)
+ for _, b := range route.Bindings {
+ if b.Positional {
+ ordered = append(ordered, b)
+ }
+ }
+ sort.SliceStable(ordered, func(i, j int) bool {
+ return ordered[i].PositionalIndex < ordered[j].PositionalIndex
+ })
+ var sb strings.Builder
+ sb.WriteString(use)
+ for _, b := range ordered {
+ name := strings.TrimSpace(b.Property)
+ if name == "" {
+ name = strings.TrimSpace(b.FlagName)
+ }
+ if name == "" {
+ continue
+ }
+ sb.WriteString(" [")
+ sb.WriteString(name)
+ sb.WriteString("]")
+ }
+ use = sb.String()
+ }
+
cmd := &cobra.Command{
- Use: route.Use,
+ Use: use,
Aliases: append([]string(nil), route.Aliases...),
Short: route.Short,
Long: route.Long,
Example: route.Example,
Hidden: route.Hidden,
- Args: cobra.NoArgs,
+ Args: argsValidator,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
jsonPayload, err := cmd.Flags().GetString("json")
@@ -141,10 +234,27 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
params[key] = value
}
+ // Inject positional args into params according to each binding's
+ // PositionalIndex. Pure positional bindings are not registered as
+ // flags; dual-mode positional bindings (positional + alias) only
+ // fall through to positional injection when their flag aliases
+ // were not used (collectPositionalBindings skips when params
+ // already contains the property).
+ if err := collectPositionalBindings(args, route.Bindings, params); err != nil {
+ return err
+ }
+
// Collect schema-derived flags (from buildFlagsFromDetailSchema)
// that are not covered by explicit bindings.
collectSchemaFlags(cmd, route.Bindings, params)
+ // Required-presence check for positional bindings — must run after
+ // both flag (CollectBindings) and positional (collectPositionalBindings)
+ // have had a chance to populate params.
+ if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil {
+ return err
+ }
+
if route.Normalizer != nil {
if err := route.Normalizer(cmd, params); err != nil {
return err
@@ -180,6 +290,9 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
+ if route.OutputTransform != nil && result.Response != nil {
+ result.Response = route.OutputTransform(result.Response)
+ }
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
},
}
@@ -196,8 +309,64 @@ func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command {
return cmd
}
+// parseFlagDefault converts a string-form envelope default into the typed
+// primitives used by pflag's *P helpers. Unparseable values silently fall
+// back to the type's zero value so a malformed envelope downgrades to
+// "no default in --help" rather than a panic at startup. The slice form
+// splits on commas and trims whitespace, mirroring pflag.StringSlice
+// behavior; empty/whitespace-only segments are dropped.
+func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) {
+ trimmed := strings.TrimSpace(raw)
+ switch kind {
+ case ValueString, ValueJSON:
+ // Preserve raw (not trimmed) so explicitly-padded defaults survive.
+ defStr = raw
+ case ValueInt:
+ if trimmed != "" {
+ if v, err := strconv.Atoi(trimmed); err == nil {
+ defInt = v
+ }
+ }
+ case ValueFloat:
+ if trimmed != "" {
+ if v, err := strconv.ParseFloat(trimmed, 64); err == nil {
+ defFloat = v
+ }
+ }
+ case ValueBool:
+ if trimmed != "" {
+ if v, err := strconv.ParseBool(trimmed); err == nil {
+ defBool = v
+ }
+ }
+ case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
+ if trimmed != "" {
+ for _, p := range strings.Split(trimmed, ",") {
+ if t := strings.TrimSpace(p); t != "" {
+ defSlice = append(defSlice, t)
+ }
+ }
+ }
+ }
+ return
+}
+
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
for _, binding := range bindings {
+ // Positional bindings are collected from cobra args rather than flags.
+ // Exception: dual-mode bindings (positional + envelope-declared flag
+ // aliases) also register the aliases so users can pass either
+ // `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced
+ // later by validateRequiredPositionalBindings instead of MarkFlagRequired.
+ if binding.Positional {
+ primary := strings.TrimSpace(binding.FlagName)
+ alias := strings.TrimSpace(binding.Alias)
+ if primary == "" && alias == "" && len(binding.Aliases) == 0 {
+ continue
+ }
+ registerPositionalAliasFlags(cmd, binding)
+ continue
+ }
primary := strings.TrimSpace(binding.FlagName)
if primary == "" {
continue
@@ -206,43 +375,77 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
if alias == primary {
alias = ""
}
+ // Dedupe extra aliases against primary + single alias and each other.
+ var extras []string
+ if len(binding.Aliases) > 0 {
+ seen := map[string]bool{primary: true, "json": true, "params": true}
+ if alias != "" {
+ seen[alias] = true
+ }
+ extras = make([]string, 0, len(binding.Aliases))
+ for _, a := range binding.Aliases {
+ a = strings.TrimSpace(a)
+ if a == "" || seen[a] {
+ continue
+ }
+ seen[a] = true
+ extras = append(extras, a)
+ }
+ }
+
+ // Parse binding.Default once per binding into Kind-typed values used
+ // by both the primary and hidden-alias registrations below. Hidden
+ // aliases share the same default so users typing the legacy alias
+ // see consistent --help text and zero-value behavior.
+ defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
+
+ registerHidden := func(name string, suffix string) {
+ if name == "" {
+ return
+ }
+ switch binding.Kind {
+ case ValueString:
+ cmd.Flags().String(name, defStr, binding.Usage+suffix)
+ case ValueInt:
+ cmd.Flags().Int(name, defInt, binding.Usage+suffix)
+ case ValueFloat:
+ cmd.Flags().Float64(name, defFloat, binding.Usage+suffix)
+ case ValueBool:
+ cmd.Flags().Bool(name, defBool, binding.Usage+suffix)
+ case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
+ cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix)
+ case ValueJSON:
+ cmd.Flags().String(name, defStr, binding.Usage+suffix)
+ }
+ _ = cmd.Flags().MarkHidden(name)
+ }
switch binding.Kind {
case ValueString:
- cmd.Flags().StringP(primary, binding.Short, "", binding.Usage)
- if alias != "" {
- cmd.Flags().String(alias, "", binding.Usage+" (alias)")
- _ = cmd.Flags().MarkHidden(alias)
- }
+ cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
case ValueInt:
- cmd.Flags().IntP(primary, binding.Short, 0, binding.Usage)
- if alias != "" {
- cmd.Flags().Int(alias, 0, binding.Usage+" (alias)")
- _ = cmd.Flags().MarkHidden(alias)
- }
+ cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
case ValueFloat:
- cmd.Flags().Float64P(primary, binding.Short, 0, binding.Usage)
- if alias != "" {
- cmd.Flags().Float64(alias, 0, binding.Usage+" (alias)")
- _ = cmd.Flags().MarkHidden(alias)
- }
+ cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
case ValueBool:
- cmd.Flags().BoolP(primary, binding.Short, false, binding.Usage)
- if alias != "" {
- cmd.Flags().Bool(alias, false, binding.Usage+" (alias)")
- _ = cmd.Flags().MarkHidden(alias)
- }
+ cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
- cmd.Flags().StringSliceP(primary, binding.Short, nil, binding.Usage)
- if alias != "" {
- cmd.Flags().StringSlice(alias, nil, binding.Usage+" (alias)")
- _ = cmd.Flags().MarkHidden(alias)
- }
+ cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
case ValueJSON:
- cmd.Flags().StringP(primary, binding.Short, "", binding.Usage+" (JSON)")
- if alias != "" {
- cmd.Flags().String(alias, "", binding.Usage+" (alias, JSON)")
- _ = cmd.Flags().MarkHidden(alias)
+ cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
+ }
+ registerHidden(alias, " (alias)")
+ for _, extra := range extras {
+ registerHidden(extra, " (alias)")
+ }
+ if binding.Required {
+ // When no hidden aliases exist, lean on cobra's native required
+ // validation for the best UX (colored error, shown in --help).
+ // When aliases exist, CollectBindings does its own "any-of-these
+ // is set" check so users who type the hidden alias do not hit
+ // cobra yelling about the primary being missing.
+ if alias == "" && len(extras) == 0 {
+ _ = cmd.MarkFlagRequired(primary)
}
}
}
@@ -252,6 +455,165 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
_ = cmd.Flags().MarkHidden("params")
}
+// registerPositionalAliasFlags registers the visible primary flag and any
+// hidden aliases for a "dual-mode" positional binding (envelope:
+// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is
+// intentionally deferred to validateRequiredPositionalBindings — cobra's
+// MarkFlagRequired would yell even when the user supplied the value as a
+// positional arg.
+func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
+ primary := strings.TrimSpace(binding.FlagName)
+ alias := strings.TrimSpace(binding.Alias)
+ if alias == primary {
+ alias = ""
+ }
+
+ // Dedupe extras against primary + alias and reserved internal names.
+ seen := map[string]bool{"json": true, "params": true}
+ if primary != "" {
+ seen[primary] = true
+ }
+ if alias != "" {
+ seen[alias] = true
+ }
+ extras := make([]string, 0, len(binding.Aliases))
+ for _, a := range binding.Aliases {
+ a = strings.TrimSpace(a)
+ if a == "" || seen[a] {
+ continue
+ }
+ seen[a] = true
+ extras = append(extras, a)
+ }
+
+ defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
+
+ register := func(name string, withShort bool, hidden bool, usageSuffix string) {
+ if name == "" {
+ return
+ }
+ short := ""
+ if withShort {
+ short = binding.Short
+ }
+ usage := binding.Usage + usageSuffix
+ switch binding.Kind {
+ case ValueString:
+ cmd.Flags().StringP(name, short, defStr, usage)
+ case ValueInt:
+ cmd.Flags().IntP(name, short, defInt, usage)
+ case ValueFloat:
+ cmd.Flags().Float64P(name, short, defFloat, usage)
+ case ValueBool:
+ cmd.Flags().BoolP(name, short, defBool, usage)
+ case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
+ cmd.Flags().StringSliceP(name, short, defSlice, usage)
+ case ValueJSON:
+ cmd.Flags().StringP(name, short, defStr, usage+" (JSON)")
+ default:
+ cmd.Flags().StringP(name, short, defStr, usage)
+ }
+ if hidden {
+ _ = cmd.Flags().MarkHidden(name)
+ }
+ }
+
+ register(primary, true, false, "")
+ register(alias, false, true, " (alias)")
+ for _, e := range extras {
+ register(e, false, true, " (alias)")
+ }
+}
+
+// collectPositionalBindings pulls positional args according to the bindings
+// and injects them into params[property]. Missing slots are skipped (cobra
+// arity validation already ran before RunE).
+func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error {
+ for _, binding := range bindings {
+ if !binding.Positional {
+ continue
+ }
+ property := strings.TrimSpace(binding.Property)
+ if property == "" {
+ continue
+ }
+ // Dual-mode positional: if the user already provided the value via a
+ // flag alias (CollectBindings wrote it), honor flag > positional.
+ if _, ok := params[property]; ok {
+ continue
+ }
+ if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) {
+ continue
+ }
+ raw := args[binding.PositionalIndex]
+ switch binding.Kind {
+ case ValueInt:
+ v, err := strconv.Atoi(strings.TrimSpace(raw))
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property))
+ }
+ params[property] = v
+ case ValueFloat:
+ v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property))
+ }
+ params[property] = v
+ case ValueBool:
+ v, err := strconv.ParseBool(strings.TrimSpace(raw))
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property))
+ }
+ params[property] = v
+ default:
+ params[property] = raw
+ }
+ }
+ return nil
+}
+
+// validateRequiredPositionalBindings enforces required-presence for positional
+// bindings whose original envelope spec set required=true. The arity validator
+// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs
+// excluding the dual slot) so a flag-only invocation is permitted; this check
+// closes the loop by rejecting the case where neither the positional arg nor
+// any flag alias was supplied.
+func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error {
+ for _, binding := range bindings {
+ if !binding.Positional || !binding.Required {
+ continue
+ }
+ property := strings.TrimSpace(binding.Property)
+ if property == "" {
+ continue
+ }
+ if v, ok := params[property]; ok {
+ if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
+ continue
+ }
+ }
+ // Compose candidate flag names so the error message points users at
+ // the first writable label even for flag-only invocations.
+ primary := strings.TrimSpace(binding.FlagName)
+ alias := strings.TrimSpace(binding.Alias)
+ if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed {
+ continue
+ }
+ display := primary
+ if display == "" {
+ display = alias
+ }
+ if display == "" && len(binding.Aliases) > 0 {
+ display = binding.Aliases[0]
+ }
+ if display == "" {
+ return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property))
+ }
+ return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property))
+ }
+ return nil
+}
+
// collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that
// have no explicit FlagBinding. This bridges the gap for plugin-defined tools
// whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags.
@@ -265,6 +627,11 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
if a := strings.TrimSpace(b.Alias); a != "" {
bound[a] = true
}
+ for _, extra := range b.Aliases {
+ if e := strings.TrimSpace(extra); e != "" {
+ bound[e] = true
+ }
+ }
}
// Reserved/internal flags that should never be forwarded as tool params.
@@ -316,36 +683,78 @@ func toOriginalParamName(flagName string) string {
return strings.ReplaceAll(flagName, "-", "_")
}
+// firstChangedFlag returns the first name (in order) whose cobra flag has
+// been set by the user. Whitespace-only or empty entries are skipped.
+// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias >
+// extraAliases in declaration order.
+func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) {
+ for _, n := range names {
+ n = strings.TrimSpace(n)
+ if n == "" {
+ continue
+ }
+ if cobracmd.FlagChanged(cmd, n) {
+ return n, true
+ }
+ }
+ return "", false
+}
+
func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) {
if existing == nil {
existing = map[string]any{}
}
params := make(map[string]any)
for _, binding := range bindings {
+ if binding.Positional {
+ // Pure positional (no flag aliases) is handled by
+ // collectPositionalBindings. Dual-mode positional bindings
+ // (envelope: positional + alias/aliases) fall through so any
+ // user-supplied flag value wins over the positional arg.
+ primary := strings.TrimSpace(binding.FlagName)
+ alias := strings.TrimSpace(binding.Alias)
+ if primary == "" && alias == "" && len(binding.Aliases) == 0 {
+ continue
+ }
+ }
primaryName := strings.TrimSpace(binding.FlagName)
if primaryName == "" {
continue
}
aliasName := strings.TrimSpace(binding.Alias)
- primaryChanged := cobracmd.FlagChanged(cmd, primaryName)
- aliasChanged := aliasName != "" && cobracmd.FlagChanged(cmd, aliasName)
- flagName := primaryName
- if aliasChanged {
- flagName = aliasName
+ // Candidate flag names in precedence order: primary, single alias,
+ // then extra aliases. Whichever is set first wins; mirrors the
+ // semantics of cmdutil.FlagOrFallback.
+ candidates := make([]string, 0, 2+len(binding.Aliases))
+ candidates = append(candidates, primaryName)
+ if aliasName != "" && aliasName != primaryName {
+ candidates = append(candidates, aliasName)
+ }
+ for _, extra := range binding.Aliases {
+ e := strings.TrimSpace(extra)
+ if e == "" || e == primaryName || e == aliasName {
+ continue
+ }
+ candidates = append(candidates, e)
+ }
+
+ flagName, anyChanged := firstChangedFlag(cmd, candidates...)
+ if !anyChanged {
+ flagName = primaryName
}
flag := cmd.Flags().Lookup(flagName)
if flag == nil {
continue
}
- if binding.Required && !primaryChanged && !aliasChanged {
+ if binding.Required && !anyChanged && !binding.Positional {
if _, ok := existing[binding.Property]; ok {
continue
}
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
}
- if !primaryChanged && !aliasChanged {
+ if !anyChanged {
continue
}
diff --git a/internal/compat/registry_test.go b/internal/compat/registry_test.go
index e2cae358..6c39d2fb 100644
--- a/internal/compat/registry_test.go
+++ b/internal/compat/registry_test.go
@@ -21,6 +21,71 @@ import (
"github.com/spf13/cobra"
)
+func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) {
+ t.Parallel()
+
+ // envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
+ bindings := []FlagBinding{
+ {
+ FlagName: "query",
+ Aliases: []string{"keyword"},
+ Property: "keyword",
+ Kind: ValueString,
+ Usage: "搜索关键词",
+ Required: true,
+ Positional: true,
+ PositionalIndex: 0,
+ },
+ }
+
+ cmd := &cobra.Command{Use: "search"}
+ ApplyBindings(cmd, bindings)
+
+ primary := cmd.Flags().Lookup("query")
+ if primary == nil {
+ t.Fatal("--query flag should be registered for dual-mode positional")
+ }
+ if primary.Hidden {
+ t.Fatal("--query flag should be visible")
+ }
+ hidden := cmd.Flags().Lookup("keyword")
+ if hidden == nil {
+ t.Fatal("--keyword alias flag should be registered")
+ }
+ if !hidden.Hidden {
+ t.Fatal("--keyword alias flag should be hidden")
+ }
+
+ // --query should NOT be marked required at cobra level — that would
+ // break flag-only invocation when arity is relaxed.
+ if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok {
+ t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)")
+ }
+}
+
+func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) {
+ t.Parallel()
+
+ // Pure positional (no Alias / no Aliases) → no flag should be registered;
+ // arity validator (set in NewDirectCommand) handles required-presence.
+ bindings := []FlagBinding{
+ {
+ Property: "text",
+ Kind: ValueString,
+ Required: true,
+ Positional: true,
+ PositionalIndex: 0,
+ },
+ }
+
+ cmd := &cobra.Command{Use: "send"}
+ ApplyBindings(cmd, bindings)
+
+ if f := cmd.Flags().Lookup("text"); f != nil {
+ t.Fatalf("pure positional should not register a flag, got %+v", f)
+ }
+}
+
func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) {
t.Parallel()
diff --git a/internal/compat/transform.go b/internal/compat/transform.go
index 75f2b99f..8f4ac389 100644
--- a/internal/compat/transform.go
+++ b/internal/compat/transform.go
@@ -20,6 +20,8 @@ import (
"strings"
"time"
+ "gopkg.in/yaml.v3"
+
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -110,6 +112,18 @@ func transformCSVToArray(value any) (any, error) {
return result, nil
}
+// transformJSONParse parses a CLI string into a structured value so callers can
+// pass complex payloads (JSON arrays/objects) through a single flag.
+//
+// Two input dialects are accepted, in order:
+// 1. Strict JSON — `[{"fieldName":"x","type":"text"}]`
+// 2. YAML (flow) — `[{fieldName: x, type: text}]`
+//
+// YAML is a superset of JSON that permits unquoted keys and strings, which
+// dramatically reduces the need for shell-level escaping. Users can therefore
+// write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes
+// around every token. The output shape is the same either way; downstream
+// consumers see the parsed Go value, not the original dialect.
func transformJSONParse(value any) (any, error) {
s, ok := toString(value)
if !ok {
@@ -119,11 +133,22 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
+ // Strict JSON first — fast path and unambiguous type promotion (numbers
+ // stay numbers, etc.).
var parsed any
- if err := json.Unmarshal([]byte(s), &parsed); err != nil {
- return nil, apperrors.NewValidation(fmt.Sprintf("json_parse: invalid JSON: %v", err))
+ if err := json.Unmarshal([]byte(s), &parsed); err == nil {
+ return parsed, nil
}
- return parsed, nil
+ // YAML (flow) fallback — accepts `{key: value}` without surrounding
+ // quotes, which is the natural form when typing at a shell prompt.
+ if err := yaml.Unmarshal([]byte(s), &parsed); err == nil {
+ return parsed, nil
+ }
+ return nil, apperrors.NewValidation(
+ "json_parse: input is not valid JSON or YAML; " +
+ "quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
+ "or pass `@path/to/file.json` to read from a file",
+ )
}
func transformEnumMap(value any, args map[string]any) (any, error) {
diff --git a/internal/compat/transform_test.go b/internal/compat/transform_test.go
new file mode 100644
index 00000000..c17f3ca0
--- /dev/null
+++ b/internal/compat/transform_test.go
@@ -0,0 +1,125 @@
+// 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 compat
+
+import (
+ "reflect"
+ "testing"
+)
+
+// TestJSONParse_StrictJSON covers the primary path: callers passing
+// canonical JSON (as generated programmatically or by agents).
+func TestJSONParse_StrictJSON(t *testing.T) {
+ t.Parallel()
+
+ input := `[{"fieldName":"title","type":"text"},{"fieldName":"count","type":"number"}]`
+ got, err := ApplyTransform(input, "json_parse", nil)
+ if err != nil {
+ t.Fatalf("strict JSON should parse, got err: %v", err)
+ }
+ arr, ok := got.([]any)
+ if !ok || len(arr) != 2 {
+ t.Fatalf("expected []any of length 2, got %T %v", got, got)
+ }
+}
+
+// TestJSONParse_YAMLFlowFallback is the motivating case: a user types an
+// ad-hoc JSON-shaped array without quoting every key and value. YAML flow
+// syntax accepts it and the parsed output is indistinguishable from the
+// strict-JSON equivalent.
+func TestJSONParse_YAMLFlowFallback(t *testing.T) {
+ t.Parallel()
+
+ // Intentionally unquoted keys, unquoted string values, and Chinese
+ // identifiers — typical of what humans type at a shell.
+ input := `[{fieldName: 标题, type: text}, {fieldName: 数量, type: number, config: {formatter: INT}}, {fieldName: 状态, type: singleSelect, config: {options: [{name: 待办}, {name: 进行中}, {name: 已完成}]}}, {fieldName: 已确认, type: checkbox}]`
+
+ got, err := ApplyTransform(input, "json_parse", nil)
+ if err != nil {
+ t.Fatalf("YAML-flow input should parse, got err: %v", err)
+ }
+ arr, ok := got.([]any)
+ if !ok {
+ t.Fatalf("expected []any, got %T", got)
+ }
+ if len(arr) != 4 {
+ t.Fatalf("expected 4 field definitions, got %d", len(arr))
+ }
+
+ // Spot-check the third entry, which is the most deeply nested.
+ third, ok := arr[2].(map[string]any)
+ if !ok {
+ t.Fatalf("arr[2] expected map[string]any, got %T", arr[2])
+ }
+ if third["fieldName"] != "状态" {
+ t.Errorf("arr[2].fieldName: want 状态, got %v", third["fieldName"])
+ }
+ config, ok := third["config"].(map[string]any)
+ if !ok {
+ t.Fatalf("arr[2].config expected map, got %T", third["config"])
+ }
+ options, ok := config["options"].([]any)
+ if !ok || len(options) != 3 {
+ t.Fatalf("arr[2].config.options: want 3 items, got %v", config["options"])
+ }
+}
+
+// TestJSONParse_EmptyString preserves the legacy behaviour of returning the
+// original value untouched when the caller passes an empty / whitespace-only
+// string, matching how other transforms treat empty input.
+func TestJSONParse_EmptyString(t *testing.T) {
+ t.Parallel()
+
+ cases := []string{"", " ", "\n\t"}
+ for _, in := range cases {
+ got, err := ApplyTransform(in, "json_parse", nil)
+ if err != nil {
+ t.Errorf("empty input %q should not error: %v", in, err)
+ continue
+ }
+ if !reflect.DeepEqual(got, in) {
+ t.Errorf("empty input %q should pass through, got %v", in, got)
+ }
+ }
+}
+
+// TestJSONParse_NonString passes through non-string inputs (already-parsed
+// values flowing through the pipeline).
+func TestJSONParse_NonString(t *testing.T) {
+ t.Parallel()
+
+ preParsed := []any{map[string]any{"k": "v"}}
+ got, err := ApplyTransform(preParsed, "json_parse", nil)
+ if err != nil {
+ t.Fatalf("non-string should pass through: %v", err)
+ }
+ if !reflect.DeepEqual(got, preParsed) {
+ t.Errorf("non-string should pass through unchanged, got %v", got)
+ }
+}
+
+// TestJSONParse_InvalidInput verifies that genuine garbage is still rejected
+// with a user-facing validation error that nudges towards `@file` syntax.
+func TestJSONParse_InvalidInput(t *testing.T) {
+ t.Parallel()
+
+ // Unterminated bracket — neither valid JSON nor valid YAML flow.
+ _, err := ApplyTransform("[{fieldName:", "json_parse", nil)
+ if err == nil {
+ t.Fatal("expected error for malformed input")
+ }
+ if msg := err.Error(); msg == "" {
+ t.Fatal("error message should be non-empty")
+ }
+}
diff --git a/internal/compat/v3_helpers_test.go b/internal/compat/v3_helpers_test.go
new file mode 100644
index 00000000..e60b7bd2
--- /dev/null
+++ b/internal/compat/v3_helpers_test.go
@@ -0,0 +1,171 @@
+// 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
+
+package compat
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map.
+func TestKindFromTypeName(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in string
+ want ValueKind
+ }{
+ {"", ValueString},
+ {"string", ValueString},
+ {"STRING", ValueString},
+ {"int", ValueInt},
+ {"integer", ValueInt},
+ {"number", ValueInt},
+ {"bool", ValueBool},
+ {"boolean", ValueBool},
+ {"stringSlice", ValueStringSlice},
+ {"string_slice", ValueStringSlice},
+ {"[]string", ValueStringSlice},
+ {"weird-unknown-type", ValueString},
+ }
+ for _, tc := range cases {
+ if got := kindFromTypeName(tc.in); got != tc.want {
+ t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want)
+ }
+ }
+}
+
+// TestApplyOmitWhen verifies all three modes plus zero-detection.
+func TestApplyOmitWhen(t *testing.T) {
+ t.Parallel()
+
+ t.Run("mode_zero_drops_zero_values", func(t *testing.T) {
+ params := map[string]any{
+ "a": 0,
+ "b": "",
+ "c": false,
+ "d": []any{},
+ "e": map[string]any{},
+ "f": "non-empty",
+ "g": 42,
+ }
+ for k := range params {
+ applyOmitWhen(params, k, "zero")
+ }
+ for _, k := range []string{"a", "b", "c", "d", "e"} {
+ if _, ok := params[k]; ok {
+ t.Errorf("expected key %q to be dropped by omitWhen=zero", k)
+ }
+ }
+ for _, k := range []string{"f", "g"} {
+ if _, ok := params[k]; !ok {
+ t.Errorf("expected key %q to be preserved", k)
+ }
+ }
+ })
+
+ t.Run("mode_never_preserves_zero_values", func(t *testing.T) {
+ params := map[string]any{"explicitFalse": false, "explicitZero": 0}
+ applyOmitWhen(params, "explicitFalse", "never")
+ applyOmitWhen(params, "explicitZero", "never")
+ if len(params) != 2 {
+ t.Errorf("expected both keys preserved under 'never', got %v", params)
+ }
+ })
+
+ t.Run("mode_empty_is_noop", func(t *testing.T) {
+ params := map[string]any{"a": ""}
+ applyOmitWhen(params, "a", "empty")
+ if _, ok := params["a"]; !ok {
+ t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)")
+ }
+ })
+
+ t.Run("missing_key_safe", func(t *testing.T) {
+ params := map[string]any{}
+ applyOmitWhen(params, "nope", "zero")
+ if len(params) != 0 {
+ t.Errorf("expected no-op on missing key, got %v", params)
+ }
+ })
+}
+
+// TestIsZeroValue covers every branch of the helper.
+func TestIsZeroValue(t *testing.T) {
+ t.Parallel()
+ zeros := []any{
+ nil,
+ "",
+ " ",
+ false,
+ 0,
+ int64(0),
+ float64(0),
+ []any{},
+ []string{},
+ map[string]any{},
+ }
+ for i, z := range zeros {
+ if !isZeroValue(z) {
+ t.Errorf("case %d: expected zero value for %#v", i, z)
+ }
+ }
+ nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}}
+ for i, nz := range nonZeros {
+ if isZeroValue(nz) {
+ t.Errorf("case %d: expected non-zero for %#v", i, nz)
+ }
+ }
+}
+
+// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve.
+func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) {
+ // NOTE: not t.Parallel — edition.Get() global state is shared.
+ resolvers := runtimeDefaultResolvers()
+ now := resolvers["$now"]
+ if now == nil {
+ t.Fatal("$now resolver missing")
+ }
+ if v, ok := now(context.Background()); !ok || v == "" {
+ t.Errorf("$now returned empty value: %q ok=%v", v, ok)
+ }
+ today := resolvers["$today"]
+ if today == nil {
+ t.Fatal("$today resolver missing")
+ }
+ if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") {
+ t.Errorf("$today returned unexpected value: %q ok=%v", v, ok)
+ }
+}
+
+// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook.
+func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) {
+ prev := edition.Get()
+ defer edition.Override(prev)
+
+ edition.Override(&edition.Hooks{
+ RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
+ return map[string]edition.RuntimeDefaultFn{
+ "$currentUserId": func(ctx context.Context) (string, bool) {
+ return "test-user-001", true
+ },
+ }
+ },
+ })
+
+ resolvers := runtimeDefaultResolvers()
+ fn := resolvers["$currentUserId"]
+ if fn == nil {
+ t.Fatal("$currentUserId missing after overlay install")
+ }
+ if v, ok := fn(context.Background()); !ok || v != "test-user-001" {
+ t.Errorf("$currentUserId=%q ok=%v", v, ok)
+ }
+}
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index d89360aa..621f75a7 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -95,7 +95,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
- "chat",
+ "bot",
tool,
params,
)
diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go
index 76803431..9ff27be6 100644
--- a/internal/helpers/chat_test.go
+++ b/internal/helpers/chat_test.go
@@ -43,4 +43,55 @@ func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) {
if got := runner.last.Params["robotCode"]; got != "robot-001" {
t.Fatalf("robotCode = %#v, want robot-001", got)
}
+ if got := runner.last.CanonicalProduct; got != "bot" {
+ t.Fatalf("CanonicalProduct = %q, want bot", got)
+ }
+}
+
+func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ wantTool string
+ }{
+ {
+ name: "single-chat",
+ args: []string{
+ "--users", "user-001",
+ "--robot-code", "robot-001",
+ "--title", "t",
+ "--text", "x",
+ },
+ wantTool: "batch_send_robot_msg_to_users",
+ },
+ {
+ name: "group-chat",
+ args: []string{
+ "--group", "cid-xyz",
+ "--robot-code", "robot-001",
+ "--title", "t",
+ "--text", "x",
+ },
+ wantTool: "send_robot_group_message",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ runner := &captureRunner{}
+ cmd := newChatMessageSendByBotCommand(runner)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs(tc.args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
+ }
+ if got := runner.last.CanonicalProduct; got != "bot" {
+ t.Fatalf("CanonicalProduct = %q, want bot", got)
+ }
+ if got := runner.last.Tool; got != tc.wantTool {
+ t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
+ }
+ })
+ }
}
diff --git a/internal/ir/catalog.go b/internal/ir/catalog.go
index b21850e4..1ee76716 100644
--- a/internal/ir/catalog.go
+++ b/internal/ir/catalog.go
@@ -49,6 +49,29 @@ type CLIFlagHint struct {
Alias string `json:"alias,omitempty"`
}
+// FlagOverlay carries CLI-layer transformation metadata for a single
+// MCP parameter: the flag alias the user types, the transform applied
+// before dispatch, env-var fallback, default value, and whether the flag
+// is hidden from help. Sourced from market.CLIToolOverride.Flags.
+type FlagOverlay struct {
+ Alias string `json:"alias,omitempty"`
+ Transform string `json:"transform,omitempty"`
+ TransformArgs map[string]any `json:"transform_args,omitempty"`
+ EnvDefault string `json:"env_default,omitempty"`
+ Default string `json:"default,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+}
+
+// ToolAnnotations mirrors MCP 2025+ tool annotations. All hints are
+// nullable: absence means "unknown", not "false". Populate only when the
+// source has a clear signal — don't guess.
+type ToolAnnotations struct {
+ DestructiveHint *bool `json:"destructive_hint,omitempty"`
+ ReadOnlyHint *bool `json:"read_only_hint,omitempty"`
+ IdempotentHint *bool `json:"idempotent_hint,omitempty"`
+ OpenWorldHint *bool `json:"open_world_hint,omitempty"`
+}
+
type CanonicalProduct struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
@@ -67,13 +90,16 @@ type CanonicalProduct struct {
type ToolDescriptor struct {
RPCName string `json:"rpc_name"`
CLIName string `json:"cli_name,omitempty"`
+ Group string `json:"group,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
Sensitive bool `json:"sensitive"`
+ Annotations *ToolAnnotations `json:"annotations,omitempty"`
Hidden bool `json:"hidden,omitempty"`
FlagHints map[string]CLIFlagHint `json:"flag_hints,omitempty"`
+ FlagOverlay map[string]FlagOverlay `json:"flag_overlay,omitempty"`
SourceServerKey string `json:"source_server_key"`
CanonicalPath string `json:"canonical_path"`
}
@@ -105,10 +131,58 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog {
sensitiveByTool := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
hasSensitiveOverride := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolCLIName := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
+ toolGroup := make(map[string]string)
toolHidden := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolTitleOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolDescriptionOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolFlagHints := make(map[string]map[string]CLIFlagHint, len(runtimeServer.Server.CLI.Tools))
+ toolFlagOverlay := make(map[string]map[string]FlagOverlay)
+
+ // Merge richer market.CLIToolOverride data (group, cliName, flag
+ // transforms/aliases/env-defaults) that the dynamic compat layer
+ // already consumes but the IR previously dropped.
+ for name, override := range runtimeServer.Server.CLI.ToolOverrides {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ continue
+ }
+ if cn := strings.TrimSpace(override.CLIName); cn != "" {
+ toolCLIName[trimmed] = cn
+ }
+ if gp := strings.TrimSpace(override.Group); gp != "" {
+ toolGroup[trimmed] = gp
+ }
+ if override.Hidden {
+ toolHidden[trimmed] = true
+ }
+ if override.IsSensitive {
+ sensitiveByTool[trimmed] = true
+ hasSensitiveOverride[trimmed] = true
+ }
+ if desc := strings.TrimSpace(override.Description); desc != "" && toolDescriptionOverride[trimmed] == "" {
+ toolDescriptionOverride[trimmed] = desc
+ }
+ if len(override.Flags) > 0 {
+ overlay := make(map[string]FlagOverlay, len(override.Flags))
+ for paramName, flagOverride := range override.Flags {
+ paramTrim := strings.TrimSpace(paramName)
+ if paramTrim == "" {
+ continue
+ }
+ overlay[paramTrim] = FlagOverlay{
+ Alias: strings.TrimSpace(flagOverride.Alias),
+ Transform: strings.TrimSpace(flagOverride.Transform),
+ TransformArgs: cloneTransformArgs(flagOverride.TransformArgs),
+ EnvDefault: strings.TrimSpace(flagOverride.EnvDefault),
+ Default: strings.TrimSpace(flagOverride.Default),
+ Hidden: flagOverride.Hidden,
+ }
+ }
+ if len(overlay) > 0 {
+ toolFlagOverlay[trimmed] = overlay
+ }
+ }
+ }
for _, cliTool := range runtimeServer.Server.CLI.Tools {
name := strings.TrimSpace(cliTool.Name)
if name == "" {
@@ -164,13 +238,16 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog {
tools = append(tools, ToolDescriptor{
RPCName: tool.Name,
CLIName: cliName,
+ Group: strings.TrimSpace(toolGroup[tool.Name]),
Title: title,
Description: description,
InputSchema: cloneMap(tool.InputSchema),
OutputSchema: cloneMap(tool.OutputSchema),
Sensitive: sensitive,
+ Annotations: deriveAnnotations(sensitive),
Hidden: toolHidden[tool.Name],
FlagHints: cloneFlagHints(toolFlagHints[tool.Name]),
+ FlagOverlay: cloneFlagOverlay(toolFlagOverlay[tool.Name]),
SourceServerKey: runtimeServer.Server.Key,
CanonicalPath: fmt.Sprintf("%s.%s", productID, tool.Name),
})
@@ -363,3 +440,43 @@ func cloneFlagHints(value map[string]CLIFlagHint) map[string]CLIFlagHint {
}
return out
}
+
+func cloneFlagOverlay(value map[string]FlagOverlay) map[string]FlagOverlay {
+ if len(value) == 0 {
+ return nil
+ }
+ out := make(map[string]FlagOverlay, len(value))
+ for key, overlay := range value {
+ overlay.TransformArgs = cloneTransformArgs(overlay.TransformArgs)
+ out[key] = overlay
+ }
+ return out
+}
+
+func cloneTransformArgs(value map[string]any) map[string]any {
+ if len(value) == 0 {
+ return nil
+ }
+ data, err := json.Marshal(value)
+ if err != nil {
+ return nil
+ }
+ var cloned map[string]any
+ if err := json.Unmarshal(data, &cloned); err != nil {
+ return nil
+ }
+ return cloned
+}
+
+// deriveAnnotations maps the coarse-grained Sensitive bool onto MCP 2025+
+// annotation hints. Only DestructiveHint is populated — the other hints
+// (read_only, idempotent, open_world) stay nil until the upstream tool
+// manifest advertises them explicitly. Guessing from name prefixes
+// ("list_", "get_") risks false signals for AI agents.
+func deriveAnnotations(sensitive bool) *ToolAnnotations {
+ if !sensitive {
+ return nil
+ }
+ destructive := true
+ return &ToolAnnotations{DestructiveHint: &destructive}
+}
diff --git a/internal/ir/catalog_test.go b/internal/ir/catalog_test.go
index cee2d843..6cca9f4c 100644
--- a/internal/ir/catalog_test.go
+++ b/internal/ir/catalog_test.go
@@ -216,6 +216,114 @@ func TestBuildCatalogFallsBackToRuntimeSensitiveMetadata(t *testing.T) {
}
}
+func TestBuildCatalogCarriesToolOverrideGroupAndFlagOverlay(t *testing.T) {
+ t.Parallel()
+
+ catalog := BuildCatalog([]discovery.RuntimeServer{
+ {
+ Server: market.ServerDescriptor{
+ Key: "ding-key",
+ DisplayName: "DING",
+ Endpoint: "https://example.com/server/ding",
+ CLI: market.CLIOverlay{
+ Command: "ding",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_ding_message": {
+ CLIName: "send",
+ Group: "message",
+ IsSensitive: true,
+ Flags: map[string]market.CLIFlagOverride{
+ "receiverUserIdList": {
+ Alias: "users",
+ Transform: "csv_to_array",
+ },
+ "robotCode": {
+ Alias: "robot-code",
+ EnvDefault: "DINGTALK_DING_ROBOT_CODE",
+ },
+ "remindType": {
+ Alias: "type",
+ Transform: "enum_map",
+ TransformArgs: map[string]any{
+ "_default": float64(1),
+ "app": float64(1),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Tools: []transport.ToolDescriptor{
+ {Name: "send_ding_message", Title: "发送DING消息", InputSchema: map[string]any{"type": "object"}},
+ },
+ },
+ })
+
+ if len(catalog.Products) != 1 {
+ t.Fatalf("products len = %d, want 1", len(catalog.Products))
+ }
+ product := catalog.Products[0]
+ if product.ID != "ding" {
+ t.Fatalf("product ID = %q, want ding", product.ID)
+ }
+ tool, ok := product.FindTool("send_ding_message")
+ if !ok {
+ t.Fatalf("tool not found")
+ }
+ if tool.CLIName != "send" {
+ t.Fatalf("CLIName = %q, want send", tool.CLIName)
+ }
+ if tool.Group != "message" {
+ t.Fatalf("Group = %q, want message", tool.Group)
+ }
+ if !tool.Sensitive {
+ t.Fatalf("Sensitive = false, want true (from overlay)")
+ }
+ if tool.Annotations == nil || tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
+ t.Fatalf("DestructiveHint not propagated, got %#v", tool.Annotations)
+ }
+ if tool.Annotations.ReadOnlyHint != nil {
+ t.Fatalf("ReadOnlyHint should be nil when source is unknown, got %#v", *tool.Annotations.ReadOnlyHint)
+ }
+ users := tool.FlagOverlay["receiverUserIdList"]
+ if users.Alias != "users" || users.Transform != "csv_to_array" {
+ t.Fatalf("receiverUserIdList overlay = %#v", users)
+ }
+ robot := tool.FlagOverlay["robotCode"]
+ if robot.Alias != "robot-code" || robot.EnvDefault != "DINGTALK_DING_ROBOT_CODE" {
+ t.Fatalf("robotCode overlay = %#v", robot)
+ }
+ remind := tool.FlagOverlay["remindType"]
+ if remind.Transform != "enum_map" {
+ t.Fatalf("remindType.Transform = %q, want enum_map", remind.Transform)
+ }
+ if v, _ := remind.TransformArgs["app"].(float64); v != 1 {
+ t.Fatalf("remindType.TransformArgs[app] = %v, want 1", remind.TransformArgs["app"])
+ }
+}
+
+func TestBuildCatalogLeavesAnnotationsNilForNonSensitive(t *testing.T) {
+ t.Parallel()
+
+ catalog := BuildCatalog([]discovery.RuntimeServer{
+ {
+ Server: market.ServerDescriptor{
+ Key: "doc-key",
+ DisplayName: "文档",
+ Endpoint: "https://example.com/server/doc",
+ },
+ Tools: []transport.ToolDescriptor{
+ {Name: "list_documents", InputSchema: map[string]any{"type": "object"}},
+ },
+ },
+ })
+ tool := catalog.Products[0].Tools[0]
+ if tool.Annotations != nil {
+ t.Fatalf("Annotations = %#v, want nil for non-sensitive tool", tool.Annotations)
+ }
+}
+
func TestBuildCatalogConsumesCLIRouteMetadata(t *testing.T) {
t.Parallel()
diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go
index 9e0fc9b3..1519c3ac 100644
--- a/internal/keychain/keychain.go
+++ b/internal/keychain/keychain.go
@@ -23,6 +23,13 @@ const (
// AccountToken is the account key for storing auth token data.
AccountToken = "auth-token"
+
+ // StorageDirEnv overrides the on-disk keychain storage root on
+ // platforms that use file-backed storage (macOS, Linux). It is
+ // intended for tests that need to isolate keychain state from the
+ // real user environment and from sibling test packages running in
+ // parallel. When empty, the platform default applies.
+ StorageDirEnv = "DWS_KEYCHAIN_DIR"
)
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go
index 3c2bbad6..76bf7c38 100644
--- a/internal/keychain/keychain_darwin.go
+++ b/internal/keychain/keychain_darwin.go
@@ -40,7 +40,12 @@ const (
// StorageDir returns the storage directory for a given service name on macOS.
// Uses ~/Library/Application Support/ following Apple conventions.
+// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
+// isolation), the storage root is taken from that env var instead.
func StorageDir(service string) string {
+ if override := os.Getenv(StorageDirEnv); override != "" {
+ return filepath.Join(override, service)
+ }
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".dws", "keychain", service)
diff --git a/internal/keychain/keychain_linux.go b/internal/keychain/keychain_linux.go
index f7de6c4b..78db000e 100644
--- a/internal/keychain/keychain_linux.go
+++ b/internal/keychain/keychain_linux.go
@@ -34,8 +34,13 @@ const (
)
// StorageDir returns the storage directory for a given service name.
-// Follows XDG Base Directory Specification: ~/.local/share/
+// Follows XDG Base Directory Specification: ~/.local/share/.
+// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
+// isolation), the storage root is taken from that env var instead.
func StorageDir(service string) string {
+ if override := os.Getenv(StorageDirEnv); override != "" {
+ return filepath.Join(override, service)
+ }
home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
diff --git a/internal/market/registry.go b/internal/market/registry.go
index 4963c179..59db170d 100644
--- a/internal/market/registry.go
+++ b/internal/market/registry.go
@@ -38,6 +38,7 @@ const (
type Client struct {
BaseURL string
HTTPClient *http.Client
+ Headers map[string]string
}
type ListResponse struct {
@@ -48,6 +49,26 @@ type ListResponse struct {
type ListMetadata struct {
Count int `json:"count"`
NextCursor string `json:"nextCursor"`
+ // Warnings is populated by the Portal merge service when envelopes are
+ // dropped (e.g. missing serverDeps, status != active) or when a dangling
+ // serverDeps / toolOverrides.*.serverOverride reference is detected.
+ // Old Portals without the field simply leave this nil. CLI side should
+ // treat any non-empty slice as non-fatal informational output: print to
+ // stderr so cache refreshes expose Portal drift to the user, but do not
+ // fail the discovery load.
+ //
+ // See plan fix-wukong-discovery-missing-servers Phase 4.2/4.3.
+ Warnings []ListWarning `json:"warnings,omitempty"`
+}
+
+// ListWarning describes one envelope that was filtered out of the merged
+// response or flagged for dangling references. Fields mirror the JSON emitted
+// by WukongDiscoveryRegistry.buildWarning on the Portal side; unknown reason
+// codes are passed through verbatim for forward compatibility.
+type ListWarning struct {
+ ProductID string `json:"productId"`
+ Reason string `json:"reason"`
+ Detail string `json:"detail"`
}
type ServerEnvelope struct {
@@ -97,6 +118,37 @@ type CLIOverlay struct {
Tools []CLITool `json:"tools"`
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
+ // ServerDeps declares other product IDs that this overlay depends on at
+ // runtime (e.g. chat depends on bot for cross-server tool routing).
+ // Consumed by the portal merge service for fail-fast validation; CLI side
+ // currently only stores the value for tooling/introspection.
+ ServerDeps []string `json:"serverDeps,omitempty"`
+ // Hints registers stub sub-commands under the overlay root that only
+ // print a redirect message pointing to the canonical command path. Used
+ // for deprecated command aliases and "did-you-mean" style hints.
+ // Key is the sub-command name; value describes the target path.
+ Hints map[string]CLIHintDef `json:"hintCommands,omitempty"`
+ // RedirectTo, when non-empty, turns the entire top-level product into a
+ // stub that prints "Please use: dws " and performs no work. Used
+ // for deprecated top-level products migrated to new paths (e.g.
+ // `bot → chat bot`, `message → chat message`). See schema v3 §2.6.
+ RedirectTo string `json:"redirectTo,omitempty"`
+}
+
+// CLIHintDef declares a stub sub-command that prints a redirect message.
+// The command takes no bindings and calls no tool; its sole purpose is to
+// help users migrate from an old command path to the new one.
+type CLIHintDef struct {
+ // Target is the canonical command path shown in the redirect message
+ // (e.g. "dws chat message list").
+ Target string `json:"target"`
+ // Description overrides the Short/Long help text for the hint command.
+ // Empty falls back to a generic "use: " string.
+ Description string `json:"description,omitempty"`
+ // Group optionally nests the hint under a named sub-group (same syntax
+ // as CLIToolOverride.Group with dot-separated paths). Empty means the
+ // hint is attached directly to the overlay root.
+ Group string `json:"group,omitempty"`
}
// CLIGroupDef defines a sub-command group within a CLI module.
@@ -104,25 +156,111 @@ type CLIGroupDef struct {
Description string `json:"description"`
}
+// CLIOutputFormat declares structured post-processing applied to the MCP tool
+// response before the formatter prints it. See schema v3 §2.5.
+//
+// Apply order: Drop → Rename → Columns. All three are optional.
+type CLIOutputFormat struct {
+ // Rename moves fields from src key to dst key at top level and one level
+ // of nested objects. Missing src keys are silently ignored.
+ Rename map[string]string `json:"rename,omitempty"`
+ // Drop removes these keys from the response (top level + one level deep).
+ Drop []string `json:"drop,omitempty"`
+ // Columns controls column order and subset for --format=table. Ignored in
+ // JSON output mode.
+ Columns []string `json:"columns,omitempty"`
+}
+
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
type CLIToolOverride struct {
- CLIName string `json:"cliName"`
- Description string `json:"description,omitempty"`
- Group string `json:"group,omitempty"`
- IsSensitive bool `json:"isSensitive,omitempty"`
- Hidden bool `json:"hidden,omitempty"`
- Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
- OutputFormat map[string]any `json:"outputFormat,omitempty"`
+ CLIName string `json:"cliName"`
+ Description string `json:"description,omitempty"`
+ // Example, when non-empty, is wired to cobra.Command.Example to render
+ // the "Examples:" section in --help. Mirrors hardcoded helper commands'
+ // Example field (e.g. wukong/products/oa.go list-forms). Empty value
+ // produces no Examples section. Multi-line strings keep "\n" literally.
+ Example string `json:"example,omitempty"`
+ Group string `json:"group,omitempty"`
+ IsSensitive bool `json:"isSensitive,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+ Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
+ // OutputFormat declares structured response post-processing. v3 typed form
+ // supersedes v2's untyped map[string]any, but parsing stays lenient so v2
+ // envelopes continue to deserialize (unknown keys are ignored).
+ OutputFormat CLIOutputFormat `json:"outputFormat,omitempty"`
+ // ServerOverride routes this leaf command's tool invocation to a different
+ // product's MCP server (e.g. `chat bot ...` leaves live under the `chat`
+ // command tree but call the `bot` endpoint). Empty means use the enclosing
+ // overlay's server.
+ ServerOverride string `json:"serverOverride,omitempty"`
+ // BodyWrapper, when non-empty, wraps the collected params map under this
+ // single key before the invocation is dispatched. Useful when the upstream
+ // tool expects a typed DTO wrapper (e.g. `PersonalTodoCreateVO`). Only
+ // user-provided params are wrapped; internal control keys starting with
+ // '_' (e.g. `_blocked`, `_yes`) are preserved at the top level.
+ BodyWrapper string `json:"bodyWrapper,omitempty"`
+ // MutuallyExclusive groups flag aliases that must not be set together.
+ // Each inner slice becomes one cobra.MarkFlagsMutuallyExclusive call.
+ // Example: [["group","user","open-dingtalk-id"]] for `chat message list`.
+ MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
+ // RequireOneOf groups flag aliases where at least one must be set. Each
+ // inner slice becomes one cobra.MarkFlagsOneRequired call. Typically
+ // paired with MutuallyExclusive to enforce "exactly one of".
+ RequireOneOf [][]string `json:"requireOneOf,omitempty"`
+ // RedirectTo, when non-empty, turns this entry into a stub command that
+ // prints the redirect target instead of invoking a tool. All other
+ // fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
+ // ignored. Use for deprecated leaf commands that moved to a new path.
+ RedirectTo string `json:"redirectTo,omitempty"`
}
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
type CLIFlagOverride struct {
- Alias string `json:"alias"`
+ Alias string `json:"alias"`
+ // Aliases registers additional hidden CLI flag names for the same MCP
+ // parameter. Use to preserve legacy flag names when migrating from
+ // hardcoded commands (mirrors cmdutil.ValidateRequiredFlagWithAliases /
+ // cmdutil.FlagOrFallback). All entries are registered as hidden flags
+ // (not shown in --help); values are deduped against the primary flag
+ // name and Alias, and reserved names ("json", "params") are skipped.
+ // When any alias is set by the user, the binding's Required check is
+ // satisfied and the value is written to params[Property].
+ Aliases []string `json:"aliases,omitempty"`
Transform string `json:"transform,omitempty"`
TransformArgs map[string]any `json:"transformArgs,omitempty"`
EnvDefault string `json:"envDefault,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Default string `json:"default,omitempty"`
+ // Shorthand registers a single-char flag alias (cobra StringP etc.).
+ Shorthand string `json:"shorthand,omitempty"`
+ // Required marks this flag as mandatory via cobra.MarkFlagRequired.
+ // Ignored when Positional is true (positional args have their own arity rules).
+ Required bool `json:"required,omitempty"`
+ // Description overrides the usage string displayed in --help; takes
+ // priority over the Detail API's toolDesc when non-empty.
+ Description string `json:"description,omitempty"`
+ // Positional, when true, binds this parameter to a positional CLI argument
+ // instead of a --flag. PositionalIndex (0-based) selects which arg slot.
+ Positional bool `json:"positional,omitempty"`
+ PositionalIndex int `json:"positionalIndex,omitempty"`
+ // Type explicitly declares the flag's type: "string" (default) / "int" /
+ // "bool" / "stringSlice". When set, overrides the type inferred from MCP
+ // tools/list inputSchema. See schema v3 §2.1.
+ Type string `json:"type,omitempty"`
+ // OmitWhen declares empty-value handling when building the invocation body:
+ //
+ // "empty" (default): empty string / zero-length slice-or-map → omit
+ // "zero": + zero numbers / false booleans → omit
+ // "never": always send, even at zero value (explicit-zero semantics)
+ //
+ // See schema v3 §2.2.
+ OmitWhen string `json:"omitWhen,omitempty"`
+ // RuntimeDefault, when non-empty, injects a runtime-resolved value if the
+ // user omits the flag. Allowed placeholders: "$currentUserId" / "$unionId"
+ // / "$corpId" / "$now" / "$today". Unknown placeholders → warning + skip.
+ // Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
+ // only recognises the placeholder set. See schema v3 §2.3.
+ RuntimeDefault string `json:"runtimeDefault,omitempty"`
}
type CLITool struct {
@@ -256,6 +394,9 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR
if err != nil {
return ListResponse{}, apperrors.NewDiscovery("failed to create servers request")
}
+ for k, v := range c.Headers {
+ req.Header.Set(k, v)
+ }
resp, err := c.HTTPClient.Do(req)
if err != nil {
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("servers request failed: %v", err))
@@ -289,6 +430,9 @@ func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string)
if err != nil {
return ListResponse{}, apperrors.NewDiscovery("failed to create market servers request")
}
+ for k, v := range c.Headers {
+ req.Header.Set(k, v)
+ }
resp, err := c.HTTPClient.Do(req)
if err != nil {
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("market servers request failed: %v", err))
diff --git a/internal/output/formatter.go b/internal/output/formatter.go
index dbed8105..a7979eba 100644
--- a/internal/output/formatter.go
+++ b/internal/output/formatter.go
@@ -29,9 +29,10 @@ import (
type Format string
const (
- FormatJSON Format = "json"
- FormatTable Format = "table"
- FormatRaw Format = "raw"
+ FormatJSON Format = "json"
+ FormatTable Format = "table"
+ FormatRaw Format = "raw"
+ FormatPretty Format = "pretty"
)
var preferredListKeys = []string{"items", "results", "data", "list", "records", "tools", "servers", "products"}
@@ -75,6 +76,8 @@ func Write(w io.Writer, format Format, payload any) error {
return writeRaw(w, payload)
case FormatTable:
return writeTableish(w, payload)
+ case FormatPretty:
+ return writePretty(w, payload)
default:
return WriteJSON(w, payload)
}
@@ -123,6 +126,8 @@ func normalizeFormat(raw string, fallback Format) Format {
return FormatRaw
case string(FormatTable):
return FormatTable
+ case string(FormatPretty):
+ return FormatPretty
default:
return fallback
}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go
new file mode 100644
index 00000000..e9f02b70
--- /dev/null
+++ b/internal/output/pretty.go
@@ -0,0 +1,302 @@
+// 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 output
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+
+ "github.com/fatih/color"
+)
+
+// writePretty renders the payload as ANSI-colored, human-readable text.
+// If the payload looks like a `dws schema` response (has `kind: "schema"`),
+// a schema-specific hierarchical renderer runs. Anything else falls back
+// to the table renderer so `--format pretty` is safe on any command.
+func writePretty(w io.Writer, payload any) error {
+ normalized, err := normalizePayload(payload)
+ if err != nil {
+ return err
+ }
+
+ if m, ok := normalized.(map[string]any); ok {
+ if kind, _ := m["kind"].(string); kind == "schema" {
+ return writeSchemaPretty(w, m)
+ }
+ }
+ return writeTableish(w, normalized)
+}
+
+// writeSchemaPretty handles both the list-all shape (has `products`) and
+// the single-tool shape (has `tool`). Colours are on by default via fatih/color,
+// auto-disabled when stdout is not a TTY or when NO_COLOR is set.
+func writeSchemaPretty(w io.Writer, payload map[string]any) error {
+ bold := color.New(color.Bold).SprintFunc()
+ cyan := color.New(color.FgCyan).SprintFunc()
+ green := color.New(color.FgGreen).SprintFunc()
+ yellow := color.New(color.FgYellow).SprintFunc()
+ red := color.New(color.FgRed, color.Bold).SprintFunc()
+ dim := color.New(color.Faint).SprintFunc()
+
+ if tool, ok := payload["tool"].(map[string]any); ok {
+ return writeSchemaToolPretty(w, payload, tool, bold, cyan, green, yellow, red, dim)
+ }
+ if products, ok := payload["products"].([]any); ok {
+ return writeSchemaListPretty(w, payload, products, bold, cyan, dim)
+ }
+
+ if degraded, _ := payload["degraded"].(bool); degraded {
+ reason, _ := payload["reason"].(string)
+ hint, _ := payload["hint"].(string)
+ fmt.Fprintf(w, "%s discovery degraded: %s\n", red("!"), yellow(reason))
+ if hint != "" {
+ fmt.Fprintf(w, " %s %s\n", dim("hint:"), hint)
+ }
+ return nil
+ }
+ return writeTableish(w, payload)
+}
+
+func writeSchemaListPretty(
+ w io.Writer,
+ payload map[string]any,
+ products []any,
+ bold, cyan, dim func(...any) string,
+) error {
+ count, _ := payload["count"].(float64)
+ if count == 0 {
+ count = float64(len(products))
+ }
+ fmt.Fprintf(w, "%s %s products discovered\n", bold("Catalog"), cyan(fmt.Sprintf("%d", int(count))))
+
+ for _, raw := range products {
+ p, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ id, _ := p["id"].(string)
+ name, _ := p["name"].(string)
+ desc, _ := p["description"].(string)
+ tools, _ := p["tools"].([]any)
+ fmt.Fprintf(w, "\n%s %s %s\n", bold("▸"), bold(id), dim(name))
+ if desc != "" && desc != name {
+ fmt.Fprintf(w, " %s\n", dim(desc))
+ }
+ fmt.Fprintf(w, " %s %s\n", dim("tools:"), cyan(fmt.Sprintf("%d", len(tools))))
+ shown := 0
+ for _, t := range tools {
+ tm, ok := t.(map[string]any)
+ if !ok {
+ continue
+ }
+ rpc, _ := tm["name"].(string)
+ cli, _ := tm["cli_name"].(string)
+ if cli == "" || cli == rpc {
+ fmt.Fprintf(w, " - %s\n", rpc)
+ } else {
+ fmt.Fprintf(w, " - %s %s\n", rpc, dim("→ "+cli))
+ }
+ shown++
+ if shown >= 6 && len(tools) > 8 {
+ fmt.Fprintf(w, " %s\n", dim(fmt.Sprintf("… %d more", len(tools)-shown)))
+ break
+ }
+ }
+ }
+ return nil
+}
+
+func writeSchemaToolPretty(
+ w io.Writer,
+ payload map[string]any,
+ tool map[string]any,
+ bold, cyan, green, yellow, red, dim func(...any) string,
+) error {
+ rpc, _ := tool["name"].(string)
+ cli, _ := tool["cli_name"].(string)
+ title, _ := tool["title"].(string)
+ desc, _ := tool["description"].(string)
+ group, _ := tool["group"].(string)
+ canonical, _ := tool["canonical_path"].(string)
+
+ header := rpc
+ if title != "" && title != rpc {
+ header = fmt.Sprintf("%s %s", rpc, dim(title))
+ }
+ fmt.Fprintf(w, "%s %s\n", bold("Tool"), header)
+
+ var productID string
+ if product, ok := payload["product"].(map[string]any); ok {
+ pid, _ := product["id"].(string)
+ pname, _ := product["name"].(string)
+ productID = pid
+ fmt.Fprintf(w, " %s %s %s\n", dim("product:"), pid, dim(pname))
+ }
+ if canonical != "" {
+ fmt.Fprintf(w, " %s %s\n", dim("canonical:"), canonical)
+ }
+ if cli != "" {
+ parts := []string{}
+ if productID != "" {
+ parts = append(parts, productID)
+ }
+ if group != "" {
+ parts = append(parts, strings.Split(group, ".")...)
+ }
+ parts = append(parts, cli)
+ fmt.Fprintf(w, " %s %s\n", dim("cli path:"), cyan(strings.Join(parts, " ")))
+ }
+
+ // Sensitivity + annotations in one line.
+ sensitive, _ := tool["sensitive"].(bool)
+ if sensitive {
+ fmt.Fprintf(w, " %s %s\n", dim("sensitive:"), red("yes (needs --yes)"))
+ }
+ if ann, ok := tool["annotations"].(map[string]any); ok && len(ann) > 0 {
+ var parts []string
+ for _, key := range sortedMapKeys(ann) {
+ parts = append(parts, fmt.Sprintf("%s=%v", key, ann[key]))
+ }
+ fmt.Fprintf(w, " %s %s\n", dim("annotations:"), yellow(strings.Join(parts, " ")))
+ }
+
+ if desc != "" && desc != title {
+ fmt.Fprintln(w)
+ for _, line := range strings.Split(strings.TrimSpace(desc), "\n") {
+ fmt.Fprintf(w, " %s\n", dim(line))
+ }
+ }
+
+ // Parameters section.
+ if params, ok := tool["parameters"].(map[string]any); ok && len(params) > 0 {
+ required := map[string]bool{}
+ if req, ok := tool["required"].([]any); ok {
+ for _, r := range req {
+ if s, ok := r.(string); ok {
+ required[s] = true
+ }
+ }
+ }
+ overlay := map[string]map[string]any{}
+ if ov, ok := tool["flag_overlay"].(map[string]any); ok {
+ for name, v := range ov {
+ if m, ok := v.(map[string]any); ok {
+ overlay[name] = m
+ }
+ }
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%s\n", bold("Parameters"))
+ for _, name := range sortedMapKeys(params) {
+ prop, _ := params[name].(map[string]any)
+ writeParamPretty(w, name, prop, required[name], overlay[name],
+ bold, cyan, green, yellow, red, dim)
+ }
+ }
+
+ // Output schema, if any.
+ if out, ok := tool["output_schema"].(map[string]any); ok && len(out) > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%s\n", bold("Output schema"))
+ b, _ := json.MarshalIndent(out, " ", " ")
+ fmt.Fprintf(w, " %s\n", string(b))
+ }
+
+ return nil
+}
+
+func writeParamPretty(
+ w io.Writer,
+ name string,
+ prop map[string]any,
+ required bool,
+ overlay map[string]any,
+ bold, cyan, green, yellow, red, dim func(...any) string,
+) {
+ typeStr := describeType(prop)
+ marker := " "
+ if required {
+ marker = red("*")
+ }
+
+ alias, _ := overlay["alias"].(string)
+ line := fmt.Sprintf(" %s %s", marker, bold(name))
+ if alias != "" && alias != name {
+ line += fmt.Sprintf(" %s", cyan("--"+alias))
+ }
+ line += fmt.Sprintf(" %s", dim(typeStr))
+ fmt.Fprintln(w, line)
+
+ if d, _ := prop["description"].(string); d != "" {
+ for _, ln := range strings.Split(strings.TrimSpace(d), "\n") {
+ fmt.Fprintf(w, " %s\n", dim(ln))
+ }
+ }
+
+ // enum values — render inline.
+ if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
+ vals := make([]string, 0, len(enum))
+ for _, v := range enum {
+ vals = append(vals, fmt.Sprintf("%v", v))
+ }
+ fmt.Fprintf(w, " %s %s\n", dim("enum:"), green(strings.Join(vals, ", ")))
+ }
+
+ // overlay — transform / env default / default.
+ if transform, _ := overlay["transform"].(string); transform != "" {
+ extras := transform
+ if args, ok := overlay["transform_args"].(map[string]any); ok && len(args) > 0 {
+ kvs := make([]string, 0, len(args))
+ for _, k := range sortedMapKeys(args) {
+ kvs = append(kvs, fmt.Sprintf("%s=%v", k, args[k]))
+ }
+ extras += "(" + strings.Join(kvs, ", ") + ")"
+ }
+ fmt.Fprintf(w, " %s %s\n", dim("transform:"), yellow(extras))
+ }
+ if env, _ := overlay["env_default"].(string); env != "" {
+ fmt.Fprintf(w, " %s %s\n", dim("env default:"), green("$"+env))
+ }
+ if def, _ := overlay["default"].(string); def != "" {
+ fmt.Fprintf(w, " %s %s\n", dim("default:"), green(def))
+ }
+}
+
+func describeType(prop map[string]any) string {
+ t, _ := prop["type"].(string)
+ if t == "array" {
+ if items, ok := prop["items"].(map[string]any); ok {
+ if inner, _ := items["type"].(string); inner != "" {
+ return inner + "[]"
+ }
+ }
+ return "array"
+ }
+ if t == "" {
+ return "any"
+ }
+ return t
+}
+
+func sortedMapKeys(m map[string]any) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/output/pretty_test.go b/internal/output/pretty_test.go
new file mode 100644
index 00000000..d2479203
--- /dev/null
+++ b/internal/output/pretty_test.go
@@ -0,0 +1,221 @@
+// 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 output
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/fatih/color"
+)
+
+// forceNoColor disables ANSI codes for assertion simplicity. fatih/color
+// would already do this when writing to a non-TTY, but tests use bytes.Buffer
+// so we flip the flag explicitly.
+func forceNoColor(t *testing.T) {
+ t.Helper()
+ prev := color.NoColor
+ color.NoColor = true
+ t.Cleanup(func() { color.NoColor = prev })
+}
+
+func TestPretty_SchemaListRendersProducts(t *testing.T) {
+ forceNoColor(t)
+
+ payload := map[string]any{
+ "kind": "schema",
+ "count": 2,
+ "products": []any{
+ map[string]any{
+ "id": "ding",
+ "name": "DING消息",
+ "description": "DING 消息 / 发送 / 撤回",
+ "tools": []any{
+ map[string]any{"name": "send_ding_message", "cli_name": "send"},
+ map[string]any{"name": "recall_ding_message", "cli_name": "recall"},
+ },
+ },
+ map[string]any{
+ "id": "doc",
+ "name": "钉钉文档",
+ "tools": []any{map[string]any{"name": "create_document", "cli_name": "create"}},
+ },
+ },
+ }
+
+ var buf bytes.Buffer
+ if err := writePretty(&buf, payload); err != nil {
+ t.Fatalf("writePretty() error = %v", err)
+ }
+ out := buf.String()
+
+ wants := []string{
+ "Catalog",
+ "2 products discovered",
+ "ding",
+ "send_ding_message",
+ "→ send",
+ "recall_ding_message",
+ "doc",
+ "create_document",
+ }
+ for _, want := range wants {
+ if !strings.Contains(out, want) {
+ t.Errorf("pretty list missing %q; got:\n%s", want, out)
+ }
+ }
+}
+
+func TestPretty_SchemaToolRendersAllSections(t *testing.T) {
+ forceNoColor(t)
+
+ payload := map[string]any{
+ "kind": "schema",
+ "path": "ding.send_ding_message",
+ "product": map[string]any{
+ "id": "ding",
+ "name": "DING消息",
+ },
+ "tool": map[string]any{
+ "name": "send_ding_message",
+ "cli_name": "send",
+ "canonical_path": "ding.send_ding_message",
+ "group": "message",
+ "title": "发送DING消息",
+ "description": "使用企业内机器人发送DING消息",
+ "sensitive": true,
+ "annotations": map[string]any{
+ "destructive_hint": true,
+ },
+ "parameters": map[string]any{
+ "robotCode": map[string]any{
+ "type": "string",
+ "description": "机器人Code",
+ },
+ "receiverUserIdList": map[string]any{
+ "type": "array",
+ "items": map[string]any{"type": "string"},
+ "description": "接收者用户ID列表",
+ },
+ "remindType": map[string]any{
+ "type": "number",
+ "description": "提醒类型",
+ },
+ },
+ "required": []any{"robotCode", "receiverUserIdList", "remindType"},
+ "flag_overlay": map[string]any{
+ "receiverUserIdList": map[string]any{
+ "alias": "users",
+ "transform": "csv_to_array",
+ },
+ "robotCode": map[string]any{
+ "alias": "robot-code",
+ "env_default": "DINGTALK_DING_ROBOT_CODE",
+ },
+ "remindType": map[string]any{
+ "alias": "type",
+ "transform": "enum_map",
+ "transform_args": map[string]any{
+ "app": 1,
+ "sms": 2,
+ "call": 3,
+ "_default": 1,
+ },
+ },
+ },
+ },
+ }
+
+ var buf bytes.Buffer
+ if err := writePretty(&buf, payload); err != nil {
+ t.Fatalf("writePretty() error = %v", err)
+ }
+ out := buf.String()
+
+ wants := []string{
+ "Tool send_ding_message", // header
+ "canonical:", // meta section
+ "ding.send_ding_message",
+ "cli path:",
+ "ding message send",
+ "sensitive:",
+ "yes (needs --yes)",
+ "annotations:",
+ "destructive_hint=true",
+ "Parameters",
+ "robotCode",
+ "--robot-code", // overlay alias rendered
+ "env default:",
+ "$DINGTALK_DING_ROBOT_CODE",
+ "receiverUserIdList",
+ "--users",
+ "transform:",
+ "csv_to_array",
+ "string[]", // array shorthand
+ "remindType",
+ "--type",
+ "enum_map(", // with args inlined
+ "app=1",
+ }
+ for _, want := range wants {
+ if !strings.Contains(out, want) {
+ t.Errorf("pretty tool missing %q; full output:\n%s", want, out)
+ }
+ }
+
+ // required params must show the red asterisk marker (even with color disabled
+ // the literal '*' still appears).
+ for _, req := range []string{"robotCode", "receiverUserIdList", "remindType"} {
+ // format: " * "
+ if !strings.Contains(out, "* "+req) {
+ t.Errorf("required marker missing for %q", req)
+ }
+ }
+}
+
+func TestPretty_NonSchemaFallsBackToTableish(t *testing.T) {
+ forceNoColor(t)
+
+ // Payload that doesn't have kind="schema" should fall through to
+ // the tableish renderer, not error or render a schema header.
+ payload := map[string]any{"items": []any{
+ map[string]any{"id": "a", "name": "Alice"},
+ map[string]any{"id": "b", "name": "Bob"},
+ }}
+
+ var buf bytes.Buffer
+ if err := writePretty(&buf, payload); err != nil {
+ t.Fatalf("writePretty() error = %v", err)
+ }
+ out := buf.String()
+ if strings.Contains(out, "Catalog") || strings.Contains(out, "Tool ") {
+ t.Errorf("pretty wrongly ran schema renderer on non-schema payload:\n%s", out)
+ }
+ // tableish should produce header + rows
+ for _, want := range []string{"id", "name", "Alice", "Bob"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("tableish fallback missing %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestNormalizeFormat_RecognisesPretty(t *testing.T) {
+ if got := normalizeFormat("pretty", FormatJSON); got != FormatPretty {
+ t.Errorf("normalizeFormat(pretty) = %q, want %q", got, FormatPretty)
+ }
+ if got := normalizeFormat("PRETTY", FormatJSON); got != FormatPretty {
+ t.Errorf("normalizeFormat(PRETTY) = %q, want %q", got, FormatPretty)
+ }
+}
diff --git a/pkg/authretry/authretry.go b/pkg/authretry/authretry.go
new file mode 100644
index 00000000..eedf7fe1
--- /dev/null
+++ b/pkg/authretry/authretry.go
@@ -0,0 +1,74 @@
+// 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 authretry exposes the AuthRefreshRequired marker error used by
+// edition overlays (in separate Go modules) to signal "the current access
+// token was rejected by the server; please run a force-refresh and retry".
+//
+// The package has no dependencies beyond the standard library so it can be
+// imported by both the runner (internal/app) and external overlays without
+// creating import cycles with packages such as pkg/runtimetoken.
+package authretry
+
+import (
+ stderrors "errors"
+)
+
+// AuthRefreshRequired is a marker error returned by overlays (typically from
+// the edition's ClassifyToolResult or OnAuthError hook) to ask the runner to
+// perform a one-shot ForceRefresh of the access token and retry the current
+// invocation.
+//
+// Open-source code paths never produce this type, so the runner's retry
+// branch is a no-op for editions that don't opt in.
+type AuthRefreshRequired struct {
+ // Cause is the underlying user-facing error. The runner returns Cause
+ // (not the wrapper) when refresh fails or the retry budget is exhausted,
+ // so end users always see the original diagnostic instead of an internal
+ // "auth refresh required" message.
+ Cause error
+}
+
+// Error returns the underlying cause's message, or a generic fallback when
+// Cause is nil.
+func (e *AuthRefreshRequired) Error() string {
+ if e == nil || e.Cause == nil {
+ return "authentication refresh required"
+ }
+ return e.Cause.Error()
+}
+
+// Unwrap exposes the underlying cause to errors.Is / errors.As.
+func (e *AuthRefreshRequired) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Cause
+}
+
+// As walks the error chain and returns the first *AuthRefreshRequired it
+// finds, along with true. Returns nil, false otherwise.
+//
+// Use this instead of a plain errors.As when callers care about acting on
+// the marker (force-refresh + retry) rather than just identifying the
+// underlying error class.
+func As(err error) (*AuthRefreshRequired, bool) {
+ if err == nil {
+ return nil, false
+ }
+ var target *AuthRefreshRequired
+ if stderrors.As(err, &target) {
+ return target, true
+ }
+ return nil, false
+}
diff --git a/pkg/cmdutil/leaf_merge.go b/pkg/cmdutil/leaf_merge.go
new file mode 100644
index 00000000..ebc539c0
--- /dev/null
+++ b/pkg/cmdutil/leaf_merge.go
@@ -0,0 +1,100 @@
+// 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 cmdutil
+
+import (
+ "log/slog"
+
+ "github.com/spf13/cobra"
+)
+
+// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when
+// the same-named path does not already exist. Groups recurse. On conflicts the
+// dynamic side always wins, because the discovery envelope is the runtime
+// authority: hardcoded commands are retained only as a leaf-level fallback for
+// behaviour the envelope explicitly does not declare. See
+// _docs/discovery-overlay-authority.md.
+//
+// PRECONDITION: dynamicRoot must be envelope-sourced (carry the
+// SourceAnnotation=SourceEnvelope marker set by BuildDynamicCommands via
+// MarkEnvelopeSource). Callers that might otherwise pass a helper-fallback
+// root with the same name are responsible for evicting it upstream —
+// otherwise the "envelope is authority" rule silently promotes helper leaves
+// over same-named hardcoded leaves and the overlay loses its ability to
+// override routing. The wukong overlay's RegisterProducts gates this call on
+// IsEnvelopeSourced(dynamicRoot); new callers must do the same.
+//
+// Conflict resolution table:
+//
+// dynamic hardcoded → action
+// ------- --------- -----------------------------
+// absent anything graft hardcoded subtree
+// leaf leaf dynamic wins (no-op)
+// group group recurse
+// leaf group dynamic wins, warn
+// group leaf dynamic wins, warn
+//
+// MergeHardcodedLeaves mutates dynamicRoot in place and returns it so callers
+// can chain. hardcodedRoot is treated as a donor: grafted children are
+// detached from it so their cobra parent pointer points at the new parent.
+func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command {
+ if dynamicRoot == nil || hardcodedRoot == nil {
+ return dynamicRoot
+ }
+ // Snapshot children before mutating hardcodedRoot — RemoveCommand during
+ // iteration over hardcodedRoot.Commands() is unsafe because cobra returns
+ // a slice backed by an internal field that is re-sliced on removal.
+ children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...)
+ for _, hc := range children {
+ dyn := findChildByName(dynamicRoot, hc.Name())
+ switch {
+ case dyn == nil:
+ hardcodedRoot.RemoveCommand(hc)
+ dynamicRoot.AddCommand(hc)
+ case IsLeafCmd(hc) && IsLeafCmd(dyn):
+ // Envelope is authority; hardcoded leaf is ignored.
+ case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
+ MergeHardcodedLeaves(dyn, hc)
+ default:
+ slog.Warn("overlay: shape mismatch, keeping dynamic",
+ "name", hc.Name(),
+ "dynamicIsLeaf", IsLeafCmd(dyn),
+ "hardcodedIsLeaf", IsLeafCmd(hc))
+ }
+ }
+ return dynamicRoot
+}
+
+// IsLeafCmd reports whether a command has no subcommands. Leaves carry a RunE
+// and are invocation targets; groups merely organise subcommands.
+func IsLeafCmd(cmd *cobra.Command) bool {
+ if cmd == nil {
+ return false
+ }
+ return !cmd.HasSubCommands()
+}
+
+// findChildByName scans parent's direct children for a matching Name(). A
+// local helper so pkg/cmdutil stays independent of internal/cobracmd.
+func findChildByName(parent *cobra.Command, name string) *cobra.Command {
+ if parent == nil {
+ return nil
+ }
+ for _, child := range parent.Commands() {
+ if child.Name() == name {
+ return child
+ }
+ }
+ return nil
+}
diff --git a/pkg/cmdutil/leaf_merge_test.go b/pkg/cmdutil/leaf_merge_test.go
new file mode 100644
index 00000000..6d7af287
--- /dev/null
+++ b/pkg/cmdutil/leaf_merge_test.go
@@ -0,0 +1,159 @@
+// 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 cmdutil
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// newGroup returns a group command with the given children attached.
+func newGroup(name string, children ...*cobra.Command) *cobra.Command {
+ cmd := &cobra.Command{Use: name}
+ cmd.AddCommand(children...)
+ return cmd
+}
+
+// newLeaf returns a leaf command tagged via Short so tests can verify which
+// variant (dynamic or hardcoded) wins without introducing a priority system.
+func newLeaf(name, tag string) *cobra.Command {
+ return &cobra.Command{Use: name, Short: tag}
+}
+
+func TestMergeHardcodedLeaves_NilInputs(t *testing.T) {
+ t.Parallel()
+ if got := MergeHardcodedLeaves(nil, nil); got != nil {
+ t.Fatalf("expected nil, got %v", got)
+ }
+ dyn := newGroup("root")
+ if got := MergeHardcodedLeaves(dyn, nil); got != dyn {
+ t.Fatal("expected dyn to be returned unchanged")
+ }
+ hc := newGroup("root")
+ if got := MergeHardcodedLeaves(nil, hc); got != nil {
+ t.Fatal("expected nil when dynamicRoot is nil")
+ }
+}
+
+func TestMergeHardcodedLeaves_GraftsUnknownLeaf(t *testing.T) {
+ t.Parallel()
+ dyn := newGroup("root", newLeaf("kept", "dynamic"))
+ hc := newGroup("root", newLeaf("extra", "hardcoded"))
+
+ MergeHardcodedLeaves(dyn, hc)
+
+ got := findChildByName(dyn, "extra")
+ if got == nil {
+ t.Fatal("expected extra leaf to be grafted onto dyn")
+ }
+ if got.Short != "hardcoded" {
+ t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded")
+ }
+ if findChildByName(hc, "extra") != nil {
+ t.Fatal("expected extra to be detached from hardcodedRoot")
+ }
+ if got.Parent() != dyn {
+ t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn)
+ }
+}
+
+func TestMergeHardcodedLeaves_DynamicLeafWins(t *testing.T) {
+ t.Parallel()
+ dyn := newGroup("root", newLeaf("shared", "dynamic"))
+ hc := newGroup("root", newLeaf("shared", "hardcoded"))
+
+ MergeHardcodedLeaves(dyn, hc)
+
+ got := findChildByName(dyn, "shared")
+ if got == nil {
+ t.Fatal("expected shared leaf to remain on dyn")
+ }
+ if got.Short != "dynamic" {
+ t.Fatalf("shared.Short = %q, want %q (dynamic must win)", got.Short, "dynamic")
+ }
+ if findChildByName(hc, "shared") == nil {
+ t.Fatal("expected hardcoded.shared to remain on hardcodedRoot (not grafted)")
+ }
+}
+
+func TestMergeHardcodedLeaves_RecurseGroups(t *testing.T) {
+ t.Parallel()
+ dyn := newGroup("root",
+ newGroup("space",
+ newLeaf("list", "dynamic"),
+ ),
+ )
+ hc := newGroup("root",
+ newGroup("space",
+ newLeaf("list", "hardcoded"), // dynamic wins
+ newLeaf("create", "hardcoded"), // grafted
+ ),
+ newLeaf("ping", "hardcoded"), // grafted top-level
+ )
+
+ MergeHardcodedLeaves(dyn, hc)
+
+ space := findChildByName(dyn, "space")
+ if space == nil {
+ t.Fatal("expected space group on dyn")
+ }
+ if l := findChildByName(space, "list"); l == nil || l.Short != "dynamic" {
+ t.Fatalf("space.list should remain dynamic, got %+v", l)
+ }
+ if c := findChildByName(space, "create"); c == nil || c.Short != "hardcoded" {
+ t.Fatalf("space.create should be grafted from hardcoded, got %+v", c)
+ }
+ if p := findChildByName(dyn, "ping"); p == nil || p.Short != "hardcoded" {
+ t.Fatalf("ping should be grafted from hardcoded, got %+v", p)
+ }
+}
+
+func TestMergeHardcodedLeaves_ShapeMismatch_KeepsDynamic(t *testing.T) {
+ t.Parallel()
+ // Dynamic declares `cmd` as a group; hardcoded declares `cmd` as a leaf.
+ // Shape mismatch — dynamic wins, warning logged, hardcoded leaf discarded.
+ dyn := newGroup("root",
+ newGroup("cmd", newLeaf("sub", "dynamic")),
+ )
+ hc := newGroup("root", newLeaf("cmd", "hardcoded"))
+
+ MergeHardcodedLeaves(dyn, hc)
+
+ cmd := findChildByName(dyn, "cmd")
+ if cmd == nil {
+ t.Fatal("expected cmd to remain on dyn")
+ }
+ if IsLeafCmd(cmd) {
+ t.Fatal("expected dynamic cmd to remain a group")
+ }
+ if findChildByName(cmd, "sub") == nil {
+ t.Fatal("expected cmd.sub to remain")
+ }
+}
+
+func TestIsLeafCmd(t *testing.T) {
+ t.Parallel()
+ leaf := newLeaf("x", "")
+ group := newGroup("x", newLeaf("child", ""))
+ if !IsLeafCmd(leaf) {
+ t.Fatal("expected leaf to be a leaf")
+ }
+ if IsLeafCmd(group) {
+ t.Fatal("expected group to not be a leaf")
+ }
+ if IsLeafCmd(nil) {
+ t.Fatal("expected nil to not be a leaf")
+ }
+}
diff --git a/pkg/cmdutil/provenance.go b/pkg/cmdutil/provenance.go
new file mode 100644
index 00000000..0115e23a
--- /dev/null
+++ b/pkg/cmdutil/provenance.go
@@ -0,0 +1,54 @@
+// 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 cmdutil
+
+import "github.com/spf13/cobra"
+
+// SourceAnnotation is the cobra.Command.Annotations key used to record where
+// a top-level command came from. Edition overlays (e.g. wukong) read this
+// annotation to distinguish envelope-authored dynamic commands from
+// helper-fallback commands that merely happen to share a name. Keeping the
+// key and value literals in one place prevents spelling drift between the
+// core (which sets the annotation) and overlays (which read it).
+const SourceAnnotation = "dws.source"
+
+// SourceEnvelope marks a command as authored by the discovery envelope and
+// therefore authoritative at runtime. Only commands built from a
+// market.ServerDescriptor / CLIOverlay should carry this value. Helper
+// fallbacks and other sources must leave the annotation unset.
+const SourceEnvelope = "envelope"
+
+// MarkEnvelopeSource stamps the envelope provenance annotation on cmd.
+// Safe to call on commands that may not have an Annotations map yet.
+// Callers in core code are the only ones that should invoke this — overlays
+// read the annotation but must not fabricate envelope provenance.
+func MarkEnvelopeSource(cmd *cobra.Command) {
+ if cmd == nil {
+ return
+ }
+ if cmd.Annotations == nil {
+ cmd.Annotations = map[string]string{}
+ }
+ cmd.Annotations[SourceAnnotation] = SourceEnvelope
+}
+
+// IsEnvelopeSourced reports whether cmd carries the envelope provenance
+// annotation. Commands without the annotation are treated as non-authoritative
+// (helper fallbacks, overlay-injected stubs, etc.).
+func IsEnvelopeSourced(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Annotations == nil {
+ return false
+ }
+ return cmd.Annotations[SourceAnnotation] == SourceEnvelope
+}
diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go
index 556105b4..0db286d3 100644
--- a/pkg/edition/edition.go
+++ b/pkg/edition/edition.go
@@ -55,6 +55,12 @@ type ToolCaller interface {
DryRun() bool
}
+// RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g.
+// "$currentUserId") to a concrete string value. Called lazily at RunE time.
+// Returning (_, false) is equivalent to "not registered" and falls through
+// to the next-lower default source.
+type RuntimeDefaultFn func(ctx context.Context) (string, bool)
+
// Hooks groups all edition-specific behavioural overrides. Zero values
// fall back to open-source defaults so the struct is safe to use as-is.
type Hooks struct {
@@ -88,17 +94,54 @@ type Hooks struct {
// ClassifyToolResult inspects raw MCP tool-call content and returns a typed
// error (e.g. PATError, CLIError) when the response contains a known
// gateway-auth or PAT-permission failure. nil → no special handling.
- ClassifyToolResult func(content map[string]any) error
+ // ClassifyToolResult func(content map[string]any) error
// --- product & endpoint ---
StaticServers func() []ServerInfo // non-nil → skip Market discovery
VisibleProducts func() []string // non-nil → override help visibility
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
+ // --- discovery ---
+
+ // DiscoveryURL overrides the Market API endpoint for server list.
+ // Non-empty → loadDynamicCommands uses FetchServersFromURL(DiscoveryURL)
+ // instead of the default Market base URL. Provides edition-level isolation.
+ DiscoveryURL string
+
+ // DiscoveryHeaders returns HTTP headers injected into discovery requests.
+ // Used to authenticate edition-specific endpoints.
+ DiscoveryHeaders func() map[string]string
+
+ // SupplementServers returns edition-specific MCP servers NOT registered
+ // in any Market registry. Always merged into the endpoint map alongside
+ // Market/cache results, regardless of discovery success or failure.
+ SupplementServers func() []ServerInfo
+
+ // FallbackServers returns the full server list as a safety net when
+ // Market discovery + cache both fail. Results are NOT cached so the
+ // next startup still attempts live discovery.
+ FallbackServers func() []ServerInfo
+
// AfterPersistentPreRun runs at the end of the root PersistentPreRunE after
// global setup (OAuth flag overrides, log level, output sink). Overlays use
// this for clients that bypass the MCP runner (e.g. A2A gateway).
AfterPersistentPreRun func(cmd *cobra.Command, args []string) error
+
+ // ClassifyToolResult is called before the framework's default business-error
+ // detection on MCP tool results. If it returns a non-nil error, that error
+ // is used instead of the generic CategoryAPI business error. Editions use
+ // this to return custom error types with specific exit codes (e.g. PAT
+ // authorization errors with exit code 4).
+ ClassifyToolResult func(content map[string]any) error
+
+ // --- schema v3: runtime defaults ---
+
+ // RuntimeDefaults returns resolvers for runtimeDefault placeholders (e.g.
+ // "$currentUserId" → fn). Placeholders not in the map fall through to a
+ // "not registered" warning. Open-source core returns an empty map;
+ // overlays populate the whitelist ($currentUserId / $unionId / $corpId /
+ // $now / $today). See schema v3 §2.3.
+ RuntimeDefaults func() map[string]RuntimeDefaultFn
}
var (
diff --git a/pkg/editiontest/contract.go b/pkg/editiontest/contract.go
index cc43f11d..2723e4d6 100644
--- a/pkg/editiontest/contract.go
+++ b/pkg/editiontest/contract.go
@@ -17,6 +17,7 @@
package editiontest
import (
+ "strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -85,4 +86,47 @@ func RunContractTests(t *testing.T, h *edition.Hooks) {
t.Error("VisibleProducts() must return at least one product when set")
}
})
+
+ t.Run("SupplementServers_valid_when_set", func(t *testing.T) {
+ if h.SupplementServers == nil {
+ return
+ }
+ servers := h.SupplementServers()
+ if len(servers) == 0 {
+ t.Error("SupplementServers() must return at least one server when set")
+ }
+ for i, s := range servers {
+ if s.ID == "" || s.Endpoint == "" {
+ t.Errorf("SupplementServers()[%d]: ID and Endpoint must be non-empty", i)
+ }
+ }
+ })
+
+ t.Run("FallbackServers_superset_of_supplement", func(t *testing.T) {
+ if h.FallbackServers == nil || h.SupplementServers == nil {
+ return
+ }
+ fallback := h.FallbackServers()
+ if len(fallback) == 0 {
+ t.Error("FallbackServers() must return at least one server when set")
+ }
+ fbIDs := make(map[string]bool, len(fallback))
+ for _, s := range fallback {
+ fbIDs[s.ID] = true
+ }
+ for _, s := range h.SupplementServers() {
+ if !fbIDs[s.ID] {
+ t.Errorf("SupplementServers ID %q not found in FallbackServers", s.ID)
+ }
+ }
+ })
+
+ t.Run("DiscoveryURL_valid_when_set", func(t *testing.T) {
+ if h.DiscoveryURL == "" {
+ return
+ }
+ if !strings.HasPrefix(h.DiscoveryURL, "https://") {
+ t.Errorf("DiscoveryURL must start with https://, got %q", h.DiscoveryURL)
+ }
+ })
}
diff --git a/skills/SKILL.md b/skills/SKILL.md
index 9a8b0d0e..bfc43ceb 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -1,7 +1,7 @@
---
name: dws
-description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档/钉钉文档/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、查询听记纪要时使用。
-cli_version: ">=1.0.6"
+description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。
+cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
@@ -24,39 +24,41 @@ cli_version: ">=1.0.6"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
-| `aitable` | AI表格:Base/数据表/字段/记录/附件/模板搜索 | [aitable.md](./references/products/aitable.md) |
-| `approval` | 审批:审批表单/发起实例/审批/撤销 | [simple.md](./references/products/simple.md) |
-| `attendance` | 考勤:打卡记录/排班查询 | [attendance.md](./references/products/attendance.md) |
-| `calendar` | 日历:日程/参与者/会议室/闲忙查询 | [calendar.md](./references/products/calendar.md) |
+| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
+| `attendance` | 考勤:打卡记录/排班查询/考勤规则/汇总统计 | [attendance.md](./references/products/attendance.md) |
+| `calendar` | 日历:日程/参与者/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/机器人群发/单聊/撤回/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
-| `contact` | 通讯录:用户查询(当前用户/搜索/详情)/部门查询(搜索/子部门/成员列表) | [contact.md](./references/products/contact.md) |
-| `devdoc` | 开放平台文档:搜索开发文档 | [simple.md](./references/products/simple.md) |
+| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/部门查询(搜索/成员列表) | [contact.md](./references/products/contact.md) |
+| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
-| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论 | [doc.md](./references/products/doc.md) |
-| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词 | [minutes.md](./references/products/minutes.md) |
+| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名 | [doc.md](./references/products/doc.md) |
+| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) |
+| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) |
+| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
-| `todo` | 待办:创建(含优先级/截止时间)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
-| `workbench` | 工作台:应用管理 | [workbench.md](./references/products/workbench.md) |
+| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
## 意图判断决策树
-用户提到"表格/多维表/AI表格/记录/数据" → `aitable`
-用户提到"审批/请假/报销/出差/加班" → `oa`
+用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
-用户提到"日程/日历/会议室/约会" → `calendar`
+用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
用户提到"通讯录/同事/部门/组织架构" → `contact`
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
-用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论" → `doc`
+用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
+用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
+用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
-用户提到"待办/TODO/任务提醒" → `todo`
-用户提到"工作台/应用管理" → `workbench`
+用户提到"待办/TODO/任务提醒/循环待办" → `todo`
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警)
+关键区分: doc(钉钉文档/富文本协同) vs drive(钉钉云盘/二进制文件)
+关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
@@ -68,14 +70,22 @@ cli_version: ">=1.0.6"
| 产品 | 命令 | 说明 |
|------|------|------|
| `aitable` | `base delete` | 删除整个 AI 表格,含全部数据表和记录 |
+| `aitable` | `table delete` | 删除数据表(含全部字段/视图/记录) |
+| `aitable` | `field delete` | 删除字段(该列所有值同步清空) |
+| `aitable` | `view delete` | 删除视图 |
| `aitable` | `record delete` | 删除记录(支持批量) |
+| `aitable` | `chart delete` / `dashboard delete` | 删除图表/仪表盘 |
| `calendar` | `event delete` | 删除日程,所有参与者同步取消 |
| `calendar` | `participant delete` | 移除日程参与者 |
| `calendar` | `room delete` | 取消会议室预定 |
| `chat` | `group members remove` | 移除群成员 |
-| `doc` | `delete` | 删除钉钉文档(不可恢复) |
-| `doc` | `block delete` | 删除文档块 |
+| `chat` | `message recall-by-bot` | 撤回机器人已发消息 |
+| `doc` | `block delete` | 删除文档块(不可恢复) |
+| `ding` | `message recall` | 撤回已发 DING 消息 |
+| `oa` | `approval revoke` | 撤销自己发起的审批实例 |
+| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
+| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
### 确认流程
```
@@ -92,6 +102,30 @@ Step 3 → 加 --yes 执行命令
3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。
4. 充分阅读产品参考文件,通过编写代码或直接调用指令实现用户意图。
+## 命令发现(flag / 参数以 binary 为准)
+
+产品参考文档(`references/products/*.md`)里的 flag 列表是**便于理解用途的参考**,不是权威契约。参数名称、默认值、必填约束随服务发现动态变化,**以下两个命令的输出才是调用的事实源**:
+
+```bash
+# 1) 人读视图:看 Usage / Example / Flags
+dws --help
+# 例:dws calendar event list --help
+
+# 2) 机读视图:JSON Schema + flag 别名映射 + 必填字段
+dws schema # 列出所有产品及工具
+dws schema . # 规范路径(如 calendar.list_suggested_event_times)
+dws schema " " # CLI 路径(如 "calendar event list")
+dws schema --jq '.tool.flag_overlay' # 只看 flag 别名
+dws schema --jq '.tool.required' # 只看必填字段
+```
+
+**何时用哪条路径:**
+- 只需看某个命令怎么调用 → `dws --help`
+- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → `dws schema `
+- 参考文档和 `--help` 冲突时 → **以 `--help` / `dws schema` 为准**,文档视为过期
+
+`dws schema` 输出的 `flag_overlay[key].alias` 就是实际生效的 flag 名(如 `attendeeUserIds → --attendee-user-ids`);`parameters[key]` 是原始 JSON Schema;`required` 是必填字段数组;`sensitive: true` 表示写/删操作,须先向用户确认再加 `--yes`。
+
## 错误处理
1. 遇到错误,加 `--verbose` 重试一次
2. 若 stderr 出现 `RECOVERY_EVENT_ID=`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环
@@ -102,7 +136,7 @@ Step 3 → 加 --yes 执行命令
## 详细参考 (按需读取)
-- [references/products/](./references/products/) — 各产品命令详细参考
+- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
diff --git a/skills/references/products/aitable.md b/skills/references/products/aitable.md
index ae42246e..8606dd16 100644
--- a/skills/references/products/aitable.md
+++ b/skills/references/products/aitable.md
@@ -215,11 +215,12 @@ Example:
dws aitable field update --base-id --table-id --field-id --name "新字段名"
dws aitable field update --base-id --table-id --field-id --config '{"options":[{"name":"A"},{"name":"B"}]}'
Flags:
- --base-id string Base ID (必填)
- --config string 字段配置 JSON (不修改时省略)
- --field-id string Field ID (必填)
- --name string 新字段名称 (不修改时省略)
- --table-id string Table ID (必填)
+ --ai-config string AI 字段配置 JSON(AI 字段类型专用,可选)
+ --base-id string Base ID (必填)
+ --config string 字段配置 JSON (不修改时省略)
+ --field-id string Field ID (必填)
+ --name string 新字段名称 (不修改时省略)
+ --table-id string Table ID (必填)
```
不可变更字段类型。更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id。
@@ -362,6 +363,328 @@ dws aitable record create --base-id --table-id \
> ⚠️ `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。
+### view (视图管理)
+
+视图是同一张数据表的筛选/排序/分组/可见字段组合的备用呈现。record query 可以通过视图收敛查询范围。
+
+#### 获取视图
+```
+Usage:
+ dws aitable view get [flags]
+Example:
+ dws aitable view get --base-id --table-id
+ dws aitable view get --base-id --table-id --view-ids viw1,viw2
+Flags:
+ --base-id string Base ID (必填)
+ --table-id string Table ID (必填)
+ --view-ids string View ID 列表,逗号分隔,单次最多 10 个
+```
+
+返回视图配置(过滤、排序、可见字段、分组)。不传 view-ids 返回该表全部视图。
+
+#### 创建视图
+```
+Usage:
+ dws aitable view create [flags]
+Example:
+ dws aitable view create --base-id --table-id --name "进行中看板" --view-type kanban
+Flags:
+ --base-id string Base ID (必填)
+ --config string 视图配置 JSON:过滤/排序/分组/可见字段(可选,创建后可再 update)
+ --name string 视图名称 (必填)
+ --table-id string Table ID (必填)
+ --view-type string 视图类型:grid/gallery/kanban/gantt/calendar/form 等 (必填)
+```
+
+> ⚠️ 视图类型参数是 `--view-type`,不是 `--type`。
+
+#### 更新视图
+```
+Usage:
+ dws aitable view update [flags]
+Example:
+ dws aitable view update --base-id --table-id --view-id --name "新视图名"
+Flags:
+ --base-id string Base ID (必填)
+ --config string 视图配置 JSON:过滤/排序/分组/可见字段
+ --name string 新视图名
+ --table-id string Table ID (必填)
+ --view-id string 目标 View ID (必填)
+```
+
+调整过滤、排序、分组、可见字段时使用。不重建视图即可替换配置。
+
+#### 删除视图
+```
+Usage:
+ dws aitable view delete [flags]
+Example:
+ dws aitable view delete --base-id --table-id --view-id --yes
+Flags:
+ --base-id string Base ID (必填)
+ --table-id string Table ID (必填)
+ --view-id string 待删除 View ID (必填)
+```
+
+不可逆。若是主视图或最后一个视图,删除会失败。
+
+### import (数据导入)
+
+把外部 Excel/CSV 批量写入某张数据表,按两步走:先 upload 拿 importId,再 data 触发导入。
+
+#### 准备导入上传
+```
+Usage:
+ dws aitable import upload [flags]
+Example:
+ dws aitable import upload --base-id --file-name sales.xlsx --file-size 204800
+Flags:
+ --base-id string Base ID (必填)
+ --file-name string 文件名,必须含扩展名 (必填)
+ --file-size int 文件大小(字节),>0 (必填)
+```
+
+> ⚠️ 参数是 `--file-size`,不是 `--size`。返回 uploadUrl(PUT 上传)与 importId。
+
+#### 触发导入
+```
+Usage:
+ dws aitable import data [flags]
+Example:
+ dws aitable import data --import-id --timeout 60
+Flags:
+ --import-id string import upload 返回的 importId (必填)
+ --timeout int 等待导入完成的超时秒数,默认 30
+```
+
+把上一步上传到暂存区的文件正式导入为记录;超时未完成时返回处理状态,按 importId 再次查询即可。
+
+### dashboard (仪表盘)
+
+仪表盘 = 多个图表的布局容器;图表绑定数据表/视图。创建仪表盘之前通常先查 `dashboard config-example`,拿到 JSON 骨架,再把图表塞进 `--config` 的布局里。
+
+#### 仪表盘配置示例
+```
+Usage:
+ dws aitable dashboard config-example [flags]
+Example:
+ dws aitable dashboard config-example --format json
+```
+
+返回仪表盘 `--config` 的 JSON 示例,供 create/update 复制裁剪。
+
+#### 获取仪表盘
+```
+Usage:
+ dws aitable dashboard get [flags]
+Example:
+ dws aitable dashboard get --base-id --dashboard-id
+Flags:
+ --base-id string Base ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+```
+
+返回布局和 `charts[].chartId`,可直接喂给 `chart get`。
+
+#### 创建仪表盘
+```
+Usage:
+ dws aitable dashboard create [flags]
+Example:
+ dws aitable dashboard create --base-id --config ''
+Flags:
+ --base-id string Base ID (必填)
+ --config string 仪表盘配置 JSON(名称、布局、图表列表都在里面) (必填)
+```
+
+> ⚠️ 没有独立的 `--name`;名称和布局一起放在 `--config` JSON 中。
+
+#### 更新仪表盘
+```
+Usage:
+ dws aitable dashboard update [flags]
+Example:
+ dws aitable dashboard update --base-id --dashboard-id --config ''
+Flags:
+ --base-id string Base ID (必填)
+ --config string 更新后的仪表盘配置 JSON (必填)
+ --dashboard-id string Dashboard ID (必填)
+```
+
+调整布局、增删图表一律改 `--config`。
+
+#### 删除仪表盘
+```
+Usage:
+ dws aitable dashboard delete [flags]
+Example:
+ dws aitable dashboard delete --base-id --dashboard-id --yes
+Flags:
+ --base-id string Base ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+ --reason string 删除原因
+```
+
+不可逆。
+
+#### 查看仪表盘分享状态
+```
+Usage:
+ dws aitable dashboard share get [flags]
+Example:
+ dws aitable dashboard share get --base-id --dashboard-id
+Flags:
+ --base-id string Base ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+```
+
+> ⚠️ 可能返回 404(资源不存在或未开通外链),按可重试错误处理,不要误判为参数拼错。
+
+#### 更新仪表盘分享
+```
+Usage:
+ dws aitable dashboard share update [flags]
+Example:
+ dws aitable dashboard share update --base-id --dashboard-id --enabled true
+Flags:
+ --allow-back-to-doc 是否允许从分享页返回原文档(可选)
+ --base-id string Base ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+ --enabled 是否开启外链分享 (必填)
+ --share-type string 分享类型(权限/可见范围等,可选)
+```
+
+开启后返回 shareUrl;关闭后原链接失效。
+
+### chart (图表)
+
+图表挂在某个仪表盘下,绑定数据表(可进一步绑定视图)。新建前通常先查 `chart widgets-example`。
+
+#### 图表组件示例
+```
+Usage:
+ dws aitable chart widgets-example [flags]
+Example:
+ dws aitable chart widgets-example --format json
+```
+
+返回图表 widget 的 JSON 示例,供 `chart create/update --config` 参考。
+
+#### 获取图表
+```
+Usage:
+ dws aitable chart get [flags]
+Example:
+ dws aitable chart get --base-id --dashboard-id --chart-id
+Flags:
+ --base-id string Base ID (必填)
+ --chart-id string Chart ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+```
+
+#### 创建图表
+```
+Usage:
+ dws aitable chart create [flags]
+Example:
+ dws aitable chart create --base-id --dashboard-id --config ''
+Flags:
+ --base-id string Base ID (必填)
+ --config string 图表配置 JSON:数据源、维度、度量、样式 (必填)
+ --dashboard-id string 挂载到的 Dashboard ID (必填)
+ --layout string 图表布局 JSON(位置、尺寸,可选)
+```
+
+> ⚠️ 名称和类型都在 `--config` JSON 里,没有独立 `--name` / `--type`。
+
+#### 更新图表
+```
+Usage:
+ dws aitable chart update [flags]
+Example:
+ dws aitable chart update --base-id --dashboard-id --chart-id --config ''
+Flags:
+ --base-id string Base ID (必填)
+ --chart-id string Chart ID (必填)
+ --config string 图表配置 JSON (必填)
+ --dashboard-id string Dashboard ID (必填)
+ --layout string 图表布局 JSON(位置、尺寸,可选)
+```
+
+#### 删除图表
+```
+Usage:
+ dws aitable chart delete [flags]
+Example:
+ dws aitable chart delete --base-id --dashboard-id --chart-id --yes
+Flags:
+ --base-id string Base ID (必填)
+ --chart-id string 待删除 Chart ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+ --reason string 删除原因
+```
+
+不可逆。
+
+#### 查看图表分享状态
+```
+Usage:
+ dws aitable chart share get [flags]
+Example:
+ dws aitable chart share get --base-id --dashboard-id --chart-id
+Flags:
+ --base-id string Base ID (必填)
+ --chart-id string Chart ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+```
+
+返回 `enabled` 与 `shareUrl`,用来判断是否已经对外分享。
+
+#### 更新图表分享
+```
+Usage:
+ dws aitable chart share update [flags]
+Example:
+ dws aitable chart share update --base-id --dashboard-id --chart-id --enabled true
+Flags:
+ --allow-back-to-doc 是否允许从分享页返回原文档(可选)
+ --base-id string Base ID (必填)
+ --chart-id string Chart ID (必填)
+ --dashboard-id string Dashboard ID (必填)
+ --enabled 是否开启外链分享 (必填)
+ --share-type string 分享类型(权限/可见范围等,可选)
+```
+
+### export (数据导出)
+
+把数据表/视图/整张 Base 导出为 Excel/CSV,下发下载链接;常见是异步任务,首次调用可能只返回 `taskId`,需要按 `taskId` 继续轮询直到拿到 `downloadUrl`。
+
+#### 导出数据
+```
+Usage:
+ dws aitable export data [flags]
+Example:
+ # 第一步:创建任务(按 scope 传必要参数)
+ dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000
+
+ # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
+ dws aitable export data --base-id --task-id --timeout-ms 3000
+Flags:
+ --base-id string Base ID (必填)
+ --scope string 导出范围:all / table / view
+ --table-id string Table ID(scope=table/view 时必填)
+ --view-id string View ID(scope=view 时必填)
+ --format string 导出格式:excel / csv,默认 excel
+ --task-id string 轮询已有任务的 taskId
+ --timeout-ms int 单次调用服务端等待时间(毫秒)
+```
+
+参数约束:
+
+- `scope=all`:只需 `--base-id`
+- `scope=table`:必须同时传 `--table-id`
+- `scope=view`:必须同时传 `--table-id + --view-id`
+
### template (模板搜索)
#### 搜索模板
@@ -392,6 +715,10 @@ dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id --dashboard-id --format json
dws aitable chart get --base-id --dashboard-id --chart-id --format json
+
+# 3) 写入/更新都用 --config JSON(名称、布局、图表类型都在里面)
+dws aitable dashboard create --base-id --config '' --format json
+dws aitable chart update --base-id --dashboard-id --chart-id --config '' --format json
```
要点:
@@ -399,24 +726,20 @@ dws aitable chart get --base-id --dashboard-id --chart-
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
+- dashboard/chart 的 create/update 只有 `--config`,没有独立的 `--name` / `--type`,名称与布局都在 JSON 里。
-### 导出数据(两阶段轮询)
-
-`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
+### 导入外部数据(两步走)
```bash
-# 第一步:创建任务(按 scope 传必要参数)
-dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000
+# 1) 先上传文件拿 importId
+dws aitable import upload --base-id --file-name sales.xlsx --file-size 204800 --format json
+# 把本地文件 PUT 到返回的 uploadUrl(过期前必须上传完成)
-# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
-dws aitable export data --base-id --task-id --timeout-ms 3000
+# 2) 触发导入
+dws aitable import data --import-id --timeout 60 --format json
```
-参数约束
-
-- `scope=all`:只需 `base-id`
-- `scope=table`:必须 `table-id`
-- `scope=view`:必须同时 `table-id + view-id`
+要点:`--file-size` 单位字节,必须与实际文件一致;`--timeout` 单位秒,不是毫秒。
## 意图判断
@@ -446,9 +769,37 @@ dws aitable export data --base-id --task-id --timeout-ms 300
- 修改/更新 → `record update`(需 recordId,先 `record query`)
- 删除 → `record delete` [危险](需 recordId)
+用户说"视图/筛选视图/看板/画廊":
+- 查看 → `view get`
+- 新建 → `view create`(`--view-type` 指定视图类型)
+- 修改(过滤/排序/分组/可见字段) → `view update`
+- 删除 → `view delete` [危险]
+
+用户说"导入/上传 Excel/CSV":
+- 外部文件导入 → `import upload` → PUT 文件 → `import data`(两步)
+
+用户说"导出/下载表格数据":
+- → `export data`(按 scope=all/table/view 传参,异步轮询)
+
+用户说"仪表盘/dashboard":
+- 查看布局 → `dashboard get`
+- 查看模板 → `dashboard config-example`
+- 新建/修改布局 → `dashboard create` / `dashboard update`(配置都在 `--config` JSON)
+- 删除 → `dashboard delete` [危险]
+- 对外分享 → `dashboard share get` / `dashboard share update`
+
+用户说"图表/chart":
+- 查看 → `chart get`(需要 dashboardId)
+- 查看组件模板 → `chart widgets-example`
+- 新建/修改 → `chart create` / `chart update`(配置都在 `--config` JSON)
+- 删除 → `chart delete` [危险]
+- 对外分享 → `chart share get` / `chart share update`
+
+用户说"附件/文件字段" → `attachment upload`(不要用钉盘 drive)
+
用户说"模板" → `template search`
-关键区分: base=表格文件, table=数据表, field=列, record=行
+关键区分: base=表格文件, table=数据表, field=列, record=行, view=视图, dashboard=仪表盘, chart=图表
## 核心工作流
@@ -476,15 +827,23 @@ dws aitable record create --base-id --table-id \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
-| `base get` | `tables[].tableId` | --table-id |
-| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
+| `base get` | `tables[].tableId`、`dashboards[].dashboardId` | --table-id / --dashboard-id |
+| `table get` | `fields[].fieldId`、`views[].viewId` | record 操作的 cells key;field get/update/delete;view 操作 |
+| `view get` | `viewId` | record query 按视图收敛;view update/delete |
| `record query` | `recordId` | record update/delete |
+| `dashboard get` | `charts[].chartId` | chart get/update/delete/share |
+| `import upload` | `importId`、`uploadUrl` | PUT 文件后调 import data |
+| `export data` (首次) | `taskId` | 下一轮 export data 轮询 |
+| `attachment upload` | `fileToken` | record create/update 写入 attachment 字段 |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## 注意事项
-- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
+- 所有操作使用 ID(baseId/tableId/fieldId/recordId/viewId/dashboardId/chartId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
+- dashboard/chart 的 create/update 只有 `--config`(JSON 内含名称/布局/类型),没有独立 `--name`
+- view 创建的类型参数是 `--view-type`,不是 `--type`
+- import upload 的文件大小参数是 `--file-size`,不是 `--size`
## `--filters` 筛选语法排错与使用规范(极易出错)
diff --git a/skills/references/products/calendar.md b/skills/references/products/calendar.md
index 0dde78a1..867eacd2 100644
--- a/skills/references/products/calendar.md
+++ b/skills/references/products/calendar.md
@@ -30,11 +30,17 @@ Usage:
Example:
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
+ dws calendar event create --title "Q1 复盘会" \
+ --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
+ --attendees userId1,userId2 --timezone "Asia/Shanghai" --desc "Q1 复盘"
Flags:
- --desc string 日程描述
- --end string 结束时间 ISO-8601 (必填)
- --start string 开始时间 ISO-8601 (必填)
- --title string 日程标题 (必填)
+ --attendees string 参会人 userId 列表 (逗号分隔)
+ --desc string 日程描述
+ --end string 结束时间 ISO-8601 (必填)
+ --open-dingtalk-ids string 参会人 openDingTalkId 列表 (逗号分隔)
+ --start string 开始时间 ISO-8601 (必填)
+ --timezone string 时区 (如 Asia/Shanghai)
+ --title string 日程标题 (必填)
```
### 修改日程
@@ -43,11 +49,14 @@ Usage:
dws calendar event update [flags]
Example:
dws calendar event update --id --title "新标题"
+ dws calendar event update --id --desc "议程调整" --timezone "Asia/Shanghai"
Flags:
- --end string 新结束时间
- --id string 日程 ID (必填)
- --start string 新开始时间
- --title string 新标题
+ --desc string 新描述
+ --end string 新结束时间
+ --id string 日程 ID (必填)
+ --start string 新开始时间
+ --timezone string 时区 (如 Asia/Shanghai)
+ --title string 新标题
```
### 删除日程
@@ -60,6 +69,23 @@ Flags:
--id string 日程 ID (必填)
```
+### 推荐会议时间
+```
+Usage:
+ dws calendar event suggest [flags]
+Example:
+ dws calendar event suggest --users , \
+ --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 30
+Flags:
+ --duration string 会议时长(分钟)
+ --end string 候选时段结束时间 ISO-8601 (必填)
+ --start string 候选时段开始时间 ISO-8601 (必填)
+ --timezone string 时区 (如 Asia/Shanghai)
+ --users string 参会人 userId 列表 (必填)
+```
+
+基于参会人闲忙数据给出排名后的候选时段,比 `busy search` 直接给出原始忙闲更适合"帮我约"场景。
+
### 查看参与者
```
Usage:
@@ -157,6 +183,7 @@ Flags:
- 创建/约 → `event create`
- 修改/改时间 → `event update`
- 取消/删除 → `event delete`
+- 帮我约/找个大家都有空的时间 → `event suggest`
用户说"参会人/与会者":
- 查看 → `participant list`
@@ -170,7 +197,8 @@ Flags:
- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`
用户说"有空吗/忙不忙/闲忙":
-- 查询 → `busy search`
+- 要原始闲忙块 → `busy search`
+- 要直接给时段候选 → `event suggest`
## 核心工作流
diff --git a/skills/references/products/chat.md b/skills/references/products/chat.md
index 47ec2465..edcc3f18 100644
--- a/skills/references/products/chat.md
+++ b/skills/references/products/chat.md
@@ -9,21 +9,20 @@
| 子命令 | 用途 |
|-------|------|
| `group create` | 创建内部群 |
-| `group create-org` | 创建企业全员群 |
-| `group members list` | 查看群成员列表 |
+| `group members` | 查看群成员列表 |
| `group members add` | 添加群成员 |
| `group members remove` | 移除群成员(⚠️ 危险操作) |
| `group members add-bot` | 添加机器人到群 |
| `group rename` | 修改群名称 |
| `search` | 搜索群会话 |
| `search-common` | 搜索共同群 |
+| `conversation-info` | 获取会话基础信息(单聊/群聊) |
### message (会话消息管理)
| 子命令 | 用途 |
|-------|------|
| `message send` | 以当前用户身份发群消息或单聊消息 |
-| `message send-personal` | 发送个人消息(⚠️ 敏感操作) |
| `message list` | 拉取群聊或单聊会话消息 |
| `message list-all` | 按时间范围拉取当前用户所有会话消息 |
| `message list-topic-replies` | 拉取群话题回复消息列表 |
@@ -32,7 +31,6 @@
| `message list-focused` | 拉取特别关注人的消息 |
| `message list-unread-conversations` | 获取未读会话列表 |
| `message search` | 按关键词搜索消息 |
-| `message info` | 获取会话信息 |
| `message send-by-bot` | 机器人发消息(群聊或批量单聊) |
| `message recall-by-bot` | 机器人撤回消息 |
| `message send-by-webhook` | 自定义机器人 Webhook 发消息 |
@@ -43,8 +41,6 @@
| 子命令 | 用途 |
|-------|------|
| `bot search` | 搜索我的机器人 |
-| `bot create` | 创建企业机器人 |
-| `bot search-groups` | 搜索机器人所在群 |
---
@@ -64,31 +60,15 @@ Flags:
---
-## group create-org — 创建企业全员群
-
-创建面向企业组织的群,成员通过 userId 列表指定。
-
-```
-Usage:
- dws chat group create-org [flags]
-Example:
- dws chat group create-org --name "全员通知群" --users userId1,userId2
-Flags:
- --name string 群名称 (必填)
- --users string 成员 userId 列表,逗号分隔 (必填)
-```
-
----
-
-## group members list — 查看群成员列表
+## group members — 查看群成员列表
分页查询指定群聊的成员。
```
Usage:
- dws chat group members list [flags]
+ dws chat group members [flags]
Example:
- dws chat group members list --id
+ dws chat group members --id
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
@@ -227,6 +207,12 @@ Flags:
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
+ --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断)
+ --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id)
+ --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用)
+ --file-name string 文件消息的文件名
+ --file-size string 文件消息的文件大小(字节)
+ --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等)
注意:
- --text 和位置参数二选一,--text 优先
@@ -234,33 +220,7 @@ Flags:
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-users 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
- --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user
-```
-
----
-
-## message send-personal — 发送个人消息
-
-> ⚠️ 敏感操作:执行前必须向用户确认,同意后才加 `--yes`。
-
-发送个人消息到指定会话或指定用户。支持 @指定人。
-
-```
-Usage:
- dws chat message send-personal [flags]
-Example:
- dws chat message send-personal --id --content "你好" --type text
- dws chat message send-personal --open-id --content "消息内容" --type text
- dws chat message send-personal --id --content "内容" --at-all
-Flags:
- --content string 消息内容 (必填)
- --type string 消息类型,如 text、markdown (必填)
- --id string 群聊会话 ID openConversationId(与 --open-id 二选一)
- --open-id string 接收人 openDingTalkId(与 --id 二选一)
- --at-all @所有人(可选)
- --at-users string @指定人的 openDingTalkId 列表,逗号分隔(可选)
-
-注意:
- - --id(群聊会话)和 --open-id(指定用户 openDingTalkId)二选一
+ - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file
```
---
@@ -457,22 +417,22 @@ Flags:
---
-## message info — 获取会话信息
+## conversation-info — 获取会话基础信息
-获取指定群聊或单聊会话的详情信息。
+按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。
```
Usage:
- dws chat message info [flags]
+ dws chat conversation-info [flags]
Example:
- dws chat message info --id
- dws chat message info --open-id
+ dws chat conversation-info --group
+ dws chat conversation-info --open-dingtalk-id
Flags:
- --id string 群聊会话 ID openConversationId(与 --open-id 二选一)
- --open-id string 用户 openDingTalkId(单聊时与 --id 二选一)
+ --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一)
注意:
- - --id(群聊)和 --open-id(单聊用户 openDingTalkId)二选一
+ - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一
```
---
@@ -515,37 +475,6 @@ Flags:
---
-## bot create — 创建企业机器人
-
-```
-Usage:
- dws chat bot create [flags]
-Example:
- dws chat bot create --name "日报提醒机器人" --desc "负责每日日报提醒"
-Flags:
- --name string 机器人名称 (必填)
- --desc string 机器人描述(可选)
-```
-
----
-
-## bot search-groups — 搜索机器人所在群
-
-搜索指定机器人已加入的群列表。
-
-```
-Usage:
- dws chat bot search-groups [flags]
-Example:
- dws chat bot search-groups --keyword "项目"
- dws chat bot search-groups --keyword "冲刺" --cursor
-Flags:
- --keyword string 搜索关键词 (必填)
- --cursor string 分页游标(首页留空,翻页传返回的 cursor)
-```
-
----
-
## message send-by-bot — 机器人发消息
群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。
@@ -612,9 +541,8 @@ Flags:
## 意图判断
用户说"建群/创建群聊" → `chat group create`
-用户说"创建企业全员群/组织群" → `chat group create-org`
用户说"搜索群/找群" → `chat search`
-用户说"群成员/看群里有谁" → `chat group members list`
+用户说"群成员/看群里有谁" → `chat group members`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
@@ -626,7 +554,6 @@ Flags:
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时)
-用户说"发个人消息/个人通知" → `chat message send-personal`(⚠️ 敏感操作,需确认)
用户说"机器人发消息/机器人群发" → `chat message send-by-bot`
用户说"机器人撤回消息" → `chat message recall-by-bot`
用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook`
@@ -634,12 +561,10 @@ Flags:
用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all`
用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused`
用户说"查看我的机器人" → `chat bot search`
-用户说"创建机器人" → `chat bot create`
用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search`
用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common`
用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations`
-用户说"获取会话信息/会话详情" → `chat message info`
-用户说"机器人在哪些群/机器人的群" → `chat bot search-groups`
+用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info`
关键区分:
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
@@ -652,15 +577,13 @@ Flags:
- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页
- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页
- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息
-- `chat message send-personal` — 发送个人消息,支持通过 openConversationId 或 openDingTalkId 指定目标(⚠️ 敏感操作)
- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag
- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
- `chat message recall-by-bot` — 通过机器人撤回已发送的消息
-- `chat message info` — 获取指定会话的详情信息
-- `chat bot create` — 创建新的企业机器人
-- `chat bot search-groups` — 搜索机器人所在群列表
+- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等)
+- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot
## 核心工作流
@@ -715,16 +638,18 @@ dws chat message recall-by-bot --robot-code --group --format json
```
-### 创建并使用机器人(完整流程)
+### 将已有机器人加入群并发消息(完整流程)
+
+机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。
```bash
-# Step 1: 创建机器人
-dws chat bot create --name "项目提醒机器人" --desc "项目状态提醒" --format json
+# Step 1: 搜索我的机器人 — 提取 robotCode
+dws chat bot search --name "项目提醒" --format json
# Step 2: 搜索群 — 提取 openConversationId
dws chat search --query "项目群" --format json
-# Step 3: 将机器人添加到群
+# Step 3: 将机器人添加到群(需当前用户对该群有管理权限)
dws chat group members add-bot --id --robot-code --format json
# Step 4: 机器人发消息
@@ -775,8 +700,8 @@ dws chat message send --group \
| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code |
-| `chat bot create` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
+| `chat conversation-info` | `openConversationId` / 成员信息 | 作为 message send/list 等后续操作的会话上下文确认 |
| `chat message search` | `nextCursor` | 下次 message search 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 |
@@ -785,7 +710,6 @@ dws chat message send --group \
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id`
-- `chat message send-personal` 为敏感操作(isSensitive),执行前需用户明确确认
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识
diff --git a/skills/references/products/contact.md b/skills/references/products/contact.md
index 4f00a6d2..089edc69 100644
--- a/skills/references/products/contact.md
+++ b/skills/references/products/contact.md
@@ -17,9 +17,9 @@ Example:
Usage:
dws contact user search [flags]
Example:
- dws contact user search --keyword "张三"
+ dws contact user search --query "张三"
Flags:
- --keyword string 搜索关键词 (必填)
+ --query string 搜索关键词 (必填)
```
#### 按手机号搜索用户
@@ -49,9 +49,9 @@ Flags:
Usage:
dws contact dept search [flags]
Example:
- dws contact dept search --keyword "技术部"
+ dws contact dept search --query "技术部"
Flags:
- --keyword string 搜索关键词 (必填)
+ --query string 搜索关键词 (必填)
```
#### 查看部门成员
@@ -79,10 +79,10 @@ Flags:
dws contact user get-self --format json
# 2. 按名字搜索同事 — 提取 userId
-dws contact user search --keyword "张三" --format json
+dws contact user search --query "张三" --format json
# 3. 查看部门结构 — 提取 deptId
-dws contact dept search --keyword "技术部" --format json
+dws contact dept search --query "技术部" --format json
# 4. 查看部门成员
dws contact dept list-members --ids --format json
@@ -93,7 +93,6 @@ dws contact dept list-members --ids --format json
| 操作 | 提取 | 用于 |
|------|------|------|
| `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 |
-| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `dept search` | `deptId` | dept list-members 的 --ids |
## 注意事项
diff --git a/skills/references/products/devdoc.md b/skills/references/products/devdoc.md
new file mode 100644
index 00000000..e6e3e239
--- /dev/null
+++ b/skills/references/products/devdoc.md
@@ -0,0 +1,48 @@
+# 开放平台文档 (devdoc) 命令参考
+
+搜索钉钉**开放平台**开发文档,用于回答开发者关于 OpenAPI、字段、错误码、接入指南、配额等技术问题。
+
+## 命令总览
+
+### 搜索开发文档
+```
+Usage:
+ dws devdoc article search [flags]
+Example:
+ dws devdoc article search --query "OAuth2 接入"
+ dws devdoc article search --query "消息卡片" --page 2 --size 5
+ dws devdoc article search --query "机器人" --size 10
+Flags:
+ --query string 搜索关键词 (必填)
+ --page int 分页页码 (从 1 开始,默认 1)
+ --size int 分页大小 (默认 10)
+```
+
+## 意图判断
+
+用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
+- 走 `devdoc article search`,把用户问的关键短语作为 `--query`
+
+关键区分:
+- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
+- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
+
+## 核心工作流
+
+```bash
+# 开发者问"OAuth2 怎么接"
+dws devdoc article search --query "OAuth2 接入" --format json
+
+# 命中结果多时翻页
+dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
+
+# 查错误码 / 字段含义
+dws devdoc article search --query "errcode 40078" --format json
+```
+
+## 注意事项
+
+- `--query` 必填;建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
+- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
+- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
+- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
diff --git a/skills/references/products/doc.md b/skills/references/products/doc.md
index 5fa71e6e..52ebff5a 100644
--- a/skills/references/products/doc.md
+++ b/skills/references/products/doc.md
@@ -80,6 +80,22 @@ Flags:
--markdown string 文档初始 Markdown 内容
```
+### 创建其他类型文件 (表格/脑图/白板/多维表/画板)
+```
+Usage:
+ dws doc file create [flags]
+Example:
+ dws doc file create --name "项目周报" --type adoc
+ dws doc file create --name "数据统计" --type axls --folder
+ dws doc file create --name "思维导图" --type amind --workspace
+ dws doc file create --name "子文件夹" --type folder
+Flags:
+ --name string 文件名称 (必填)
+ --type string 文件类型 (必填): adoc=文档, axls=表格, appt=演示, adraw=白板, amind=脑图, able=多维表, folder=文件夹
+ --folder string 目标文件夹 ID 或 URL
+ --workspace string 目标知识库 ID 或 URL
+```
+
### 更新文档内容
```
Usage:
@@ -135,6 +151,45 @@ Flags:
--workspace string 目标知识库 ID
```
+### 复制文档/文件
+```
+Usage:
+ dws doc copy [flags]
+Example:
+ dws doc copy --node --folder
+ dws doc copy --node --workspace
+ dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/" --folder
+Flags:
+ --node string 源文档/文件 ID 或 URL (必填)
+ --folder string 目标文件夹 ID 或 URL
+ --workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录)
+```
+
+### 移动文档/文件
+```
+Usage:
+ dws doc move [flags]
+Example:
+ dws doc move --node --folder
+ dws doc move --node --workspace
+Flags:
+ --node string 源文档/文件 ID 或 URL (必填)
+ --folder string 目标文件夹 ID 或 URL
+ --workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录)
+```
+
+### 重命名文档/文件
+```
+Usage:
+ dws doc rename [flags]
+Example:
+ dws doc rename --node --name "新名称"
+ dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/" --name "项目周报 v2"
+Flags:
+ --node string 文档/文件 ID 或 URL (必填)
+ --name string 新名称 (必填)
+```
+
### 查询块元素
```
Usage:
@@ -229,6 +284,24 @@ Flags:
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
+### 创建划词评论 (内联评论)
+```
+Usage:
+ dws doc comment create-inline [flags]
+Example:
+ dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改"
+ dws doc comment create-inline --node --block-id --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
+ dws doc comment create-inline --node --block-id --start 0 --end 10 --content "请review" --mention uid1,uid2
+Flags:
+ --node string 目标文档的标识,支持传入 URL 或 ID (必填)
+ --block-id string 评论锚定所在的块 ID (必填,可通过 dws doc block list 获取)
+ --start int 选中文本在块内的起始字符偏移量,从 0 开始 (必填)
+ --end int 选中文本在块内的结束字符偏移量,必须大于 start (必填)
+ --content string 评论的文字内容,纯文本 (必填)
+ --selected-text string 选中文本内容,填写后评论列表会展示「引用原文:xxx」
+ --mention string 被 @ 的用户 uid 列表,逗号分隔
+```
+
### 回复文档评论
```
Usage:
@@ -282,12 +355,24 @@ Flags:
- 元信息 → `info`
用户说"写文档/创建文档":
-- 新建 → `create`
+- 新建纯文档 (adoc) → `create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
+用户说"新建表格/脑图/白板/多维表/演示文稿":
+- 用 `file create --type` 指定类型 (axls/amind/adraw/able/appt/adraw)
+
用户说"建文件夹/新建目录":
-- 创建 → `folder create`
+- 创建 → `folder create` 或 `file create --type folder`
+
+用户说"复制文档/拷贝一份":
+- 复制 → `copy` (需源 --node 和目标 --folder/--workspace)
+
+用户说"移动文档/换个目录":
+- 移动 → `move` (需源 --node 和目标 --folder/--workspace)
+
+用户说"改文档名字/重命名":
+- 改名 → `rename` (需 --node 和新 --name)
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `upload`(需本地文件路径)
@@ -390,7 +475,7 @@ dws doc block delete --node --block-id --yes
# 1. 查看文档的所有评论
dws doc comment list --node --format json
-# 2. 在文档上创建评论
+# 2. 在文档上创建全文评论
dws doc comment create --node --content "这里需要补充数据来源" --format json
# 3. 创建评论并 @ 相关人
@@ -398,11 +483,34 @@ dws doc comment create --node --content "这里需要补充数据来源
# 再将 userId 传入 --mention
dws doc comment create --node --content "请确认这部分内容" --mention , --format json
-# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
+# 4. 对某段文字创建划词评论(需先 block list 拿 blockId 和字符偏移)
+dws doc block list --node --format json
+dws doc comment create-inline --node --block-id --start 0 --end 12 \
+ --content "这里的数据要复核" --selected-text "被选中的原文片段" --format json
+
+# 5. 回复某条评论(commentKey 从 list 或 create 返回中获取)
dws doc comment reply --node --comment-key --content "已修改" --format json
-# 5. 用表情回复评论
+# 6. 用表情回复评论
dws doc comment reply --node --comment-key --content "比心" --emoji --format json
+
+# ── 工作流 8: 创建非文档类型文件 ──
+
+# 创建表格 / 脑图 / 白板 / 多维表 / 演示文稿
+dws doc file create --name "销售数据" --type axls --folder --format json
+dws doc file create --name "需求脑图" --type amind --workspace --format json
+dws doc file create --name "Q1 立项会" --type appt --format json
+
+# ── 工作流 9: 整理文档结构 (复制 / 移动 / 重命名) ──
+
+# 1. 把模板复制到目标目录作为新工作件
+dws doc copy --node --folder --format json
+
+# 2. 把文档从个人空间挪到团队知识库
+dws doc move --node --workspace --format json
+
+# 3. 重命名
+dws doc rename --node --name "项目周报 v2" --format json
```
## 上下文传递表
@@ -418,8 +526,11 @@ dws doc comment reply --node --comment-key --content "比
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
| `download` | 本地文件路径 | 下载后的文件保存位置 |
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
-| `comment create` | `commentKey` | comment reply 的 --comment-key |
-| `contact user search` | `userId` | comment create/reply 的 --mention |
+| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
+| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
+| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
+| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
+| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
## nodeId 双格式说明
@@ -448,6 +559,10 @@ dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE
- `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档
- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作
- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录
+- `create` 只能建"文档"(adoc);要建表格/脑图/白板/多维表/演示/文件夹,用 `file create --type`
+- `copy` 需要对源节点有"阅读"权限、对目标目录有"编辑"权限;`move` 需要对源节点有"管理"权限
+- `copy` / `move` 不传 `--folder` 时,`--workspace` 表示放到知识库根目录;两者都不传则回落到"我的文档"
+- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始)
## 自动化脚本
diff --git a/skills/references/products/drive.md b/skills/references/products/drive.md
new file mode 100644
index 00000000..2d1a156f
--- /dev/null
+++ b/skills/references/products/drive.md
@@ -0,0 +1,177 @@
+# 钉盘 (drive) 命令参考
+
+钉盘 = DingTalk Drive,用于云端文件存储 / 上传 / 下载 / 目录管理。不是在线文档编辑;要编辑文档请用 [doc](./doc.md)。
+
+## 命令总览
+
+### 列出钉盘目录
+```
+Usage:
+ dws drive list [flags]
+Example:
+ dws drive list
+ dws drive list --parent-id
+ dws drive list --parent-id --max 20 --order-by modifiedTime --order desc
+Flags:
+ --parent-id string 父目录 ID (不传则列根目录)
+ --space-id string 钉盘空间 ID (一般无需指定)
+ --max int 每页数量 (默认 20)
+ --next-token string 分页游标 (从上次结果的 nextToken 获取)
+ --order-by string 排序字段: name / createdTime / modifiedTime
+ --order string 排序方向: asc / desc
+ --thumbnail 是否返回缩略图链接
+```
+
+### 获取文件元信息
+```
+Usage:
+ dws drive info [flags]
+Example:
+ dws drive info --file-id
+Flags:
+ --file-id string 文件或文件夹 ID (必填)
+ --space-id string 钉盘空间 ID (一般无需指定)
+```
+
+### 创建文件夹
+```
+Usage:
+ dws drive mkdir [flags]
+Example:
+ dws drive mkdir --name "项目资料"
+ dws drive mkdir --name "子目录" --parent-id
+Flags:
+ --name string 文件夹名称 (必填)
+ --parent-id string 父目录 ID (不传则建在根目录)
+ --space-id string 钉盘空间 ID (一般无需指定)
+```
+
+### 获取下载临时链接
+```
+Usage:
+ dws drive download [flags]
+Example:
+ dws drive download --file-id
+Flags:
+ --file-id string 文件 ID (必填)
+ --space-id string 钉盘空间 ID (一般无需指定)
+```
+
+### 获取上传凭证 (上传第一步)
+```
+Usage:
+ dws drive upload-info [flags]
+Example:
+ dws drive upload-info --file-name "report.pdf" --file-size 102400
+ dws drive upload-info --file-name "slides.pptx" --file-size 512000 --parent-id
+Flags:
+ --file-name string 文件名含后缀 (必填)
+ --file-size int 文件大小,单位字节 (必填)
+ --mime-type string MIME 类型 (可选,服务端会自动推断)
+ --parent-id string 父目录 ID (不传则上传到根目录)
+ --space-id string 钉盘空间 ID (一般无需指定)
+```
+
+### 提交上传 (上传第三步)
+```
+Usage:
+ dws drive commit [flags]
+Example:
+ dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id
+Flags:
+ --file-name string 文件名含后缀 (必填,须与 upload-info 一致)
+ --file-size int 文件大小,单位字节 (必填,须与 upload-info 一致)
+ --upload-id string upload-info 返回的 uploadId (必填)
+ --parent-id string 父目录 ID (必填时须与 upload-info 一致)
+ --space-id string 钉盘空间 ID (一般无需指定)
+ --conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME)
+```
+
+## 意图判断
+
+用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list`
+用户说"钉盘文件详情/文件信息" → `info` (需 fileId)
+用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir`
+用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET
+用户说"上传文件到钉盘/把本地文件传钉盘":
+- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit`
+- **没有**一条聚合命令可以完成;必须完整走完三步
+
+关键区分:
+- drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容)
+- 把图片/文件发到群里一般走 drive 上传拿链接 → chat 发送 Markdown 链接 (见 [chat.md](./chat.md) 的 `drive → chat` 工作流)
+
+## 核心工作流
+
+```bash
+# ── 工作流 1: 浏览钉盘 ──
+
+# 1. 看根目录
+dws drive list --format json
+
+# 2. 进入子目录 (parentId 取自上一步的 dentryUuid)
+dws drive list --parent-id --format json
+
+# 3. 看单个文件的元信息
+dws drive info --file-id --format json
+
+# ── 工作流 2: 上传本地文件到钉盘 (三步不能省) ──
+
+# Step 1: 拿上传凭证
+dws drive upload-info --file-name "report.pdf" --file-size 102400 --parent-id --format json
+# → 返回: resourceUrl (预签名 URL), headers, uploadId
+
+# Step 2: Agent 自己发 HTTP PUT 把文件二进制推到 resourceUrl
+# 请求头必须携带返回的 headers 全部键值对;期望 HTTP 200
+curl -X PUT -T ./report.pdf -H ": " ""
+
+# Step 3: 提交入库
+dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id \
+ --parent-id --format json
+# → 返回: dentryUuid (= 后续 download/info 用的 fileId)
+
+# ── 工作流 3: 下载钉盘文件到本地 (两步) ──
+
+# Step 1: 拿临时下载 URL
+dws drive download --file-id --format json
+# → 返回: resourceUrl (带签名的临时下载 URL), expirationSeconds
+
+# Step 2: Agent 自己发 HTTP GET 下载二进制
+curl -o ./report.pdf ""
+
+# ── 工作流 4: 建目录后批量上传 ──
+
+# 1. 建目录 → 拿 folderId
+dws drive mkdir --name "2026 Q1 归档" --format json
+
+# 2. 再走 "工作流 2" 把每个文件上传到该目录
+```
+
+## 上下文传递表
+
+| 操作 | 从返回中提取 | 用于 |
+|------|-------------|------|
+| `list` | `dentryUuid` (folder 类) | 下次 list 的 --parent-id、upload-info / commit 的 --parent-id、mkdir 的 --parent-id |
+| `list` | `dentryUuid` (file 类) | info / download 的 --file-id |
+| `list` | `nextToken` | 下次 list 的 --next-token |
+| `mkdir` | `dentryUuid` | 作为父目录传给 upload-info / commit 的 --parent-id |
+| `upload-info` | `resourceUrl` + `headers` | Agent 自行执行 HTTP PUT 上传二进制 |
+| `upload-info` | `uploadId` | commit 的 --upload-id |
+| `commit` | `dentryUuid` | download / info / chat message 发送图片链接的 --file-id |
+| `download` | `resourceUrl` | Agent 自行执行 HTTP GET 下载;也可作为 Markdown 图片/附件链接发送到 chat |
+
+## 注意事项
+
+- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败
+- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit`
+- 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info`
+- `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET
+- `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配
+- `--conflict-handler` 默认 `AUTO_RENAME` (自动重命名);`OVERWRITE` 覆盖同名文件前必须和用户确认
+- `--space-id` 绝大多数场景不需要传;默认会用用户主钉盘空间
+- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
+
+## 相关产品
+
+- [doc](./doc.md) — 钉钉在线文档 / 知识库(文字、表格、脑图等富文本节点),和 drive 的裸文件存储不同
+- [chat](./chat.md) — 结合 drive 发送图片 / 附件消息到群聊(Markdown 链接语法)
diff --git a/skills/references/products/minutes.md b/skills/references/products/minutes.md
index 0ecfd74a..66436845 100644
--- a/skills/references/products/minutes.md
+++ b/skills/references/products/minutes.md
@@ -1,5 +1,7 @@
# AI听记 (minutes) 命令参考
+AI 听记:列表 / 详情 / 摘要 / 待办 / 文字稿 / 思维导图 / 发言人 / 热词 / 文件上传。
+
## 命令总览
### 查询我创建的听记列表
@@ -143,53 +145,126 @@ Flags:
--title string 新标题 (必填)
```
-### 发起听记(开始录音)
+### 覆盖听记 AI 摘要
```
Usage:
- dws minutes record start [flags]
+ dws minutes update summary [flags]
Example:
- dws minutes record start
- dws minutes record start --session-id
+ dws minutes update summary --id --content "修订后的摘要 Markdown"
Flags:
- --session-id string AI 助理会话 ID (可选)
+ --id string 听记 taskUuid (必填)
+ --content string 新的摘要正文 (必填),会整体覆盖原摘要
```
-### 暂停听记录音
+将 AI 生成的摘要替换成定制版本,适合人工修订或按业务口径重写后回写。
+
+### 全文替换听记文本
```
Usage:
- dws minutes record pause [flags]
+ dws minutes replace-text [flags]
Example:
- dws minutes record pause --id
- dws minutes record pause --id --session-id
+ dws minutes replace-text --id --search "小钉" --replace "DingTalk"
Flags:
- --id string 听记 taskUuid (必填)
- --session-id string AI 助理会话 ID (可选)
+ --id string 听记 taskUuid (必填)
+ --search string 需要替换的原文 (必填)
+ --replace string 替换后的文本 (必填)
```
-### 恢复听记录音
+在转写段落与摘要中一次性替换所有命中文本,适合纠正系统性的识别错误(如专有名词)。
+
+### 替换发言人标签
```
Usage:
- dws minutes record resume [flags]
+ dws minutes speaker replace [flags]
Example:
- dws minutes record resume --id
- dws minutes record resume --id --session-id
+ dws minutes speaker replace --id --from "说话人1" --to "张三"
+ dws minutes speaker replace --id --from "说话人1" --to "张三" --target-uid
Flags:
- --id string 听记 taskUuid (必填)
- --session-id string AI 助理会话 ID (可选)
+ --id string 听记 taskUuid (必填)
+ --from string 原发言人昵称,如 "说话人1" (必填)
+ --to string 新发言人昵称 (必填)
+ --target-uid string 绑定到指定钉钉 userId (可选)
```
-### 结束听记录音
+修正自动分离的发言人标签,可顺带把该说话人对应到真实用户。
+
+### 添加个人热词
```
Usage:
- dws minutes record stop [flags]
+ dws minutes hot-word add [flags]
Example:
- dws minutes record stop --id
- dws minutes record stop --id --session-id
+ dws minutes hot-word add --words "钉钉,悟空,DWS"
Flags:
- --id string 听记 taskUuid (必填)
- --session-id string AI 助理会话 ID (可选)
+ --words string 热词列表,逗号分隔 (必填)
```
+把专有名词/业务术语写入个人热词库,后续听记的语音识别会优先匹配,用来兜底高频误识别。
+
+### 生成思维导图
+```
+Usage:
+ dws minutes mind-graph create [flags]
+Example:
+ dws minutes mind-graph create --id
+Flags:
+ --id string 听记 taskUuid (必填)
+```
+
+异步任务:提交后返回 jobId,需要用 `mind-graph status` 轮询结果。
+
+### 查询思维导图状态
+```
+Usage:
+ dws minutes mind-graph status [flags]
+Example:
+ dws minutes mind-graph status --id
+Flags:
+ --id string 听记 taskUuid (必填)
+```
+
+轮询 `mind-graph create` 的生成任务,就绪后返回思维导图内容。
+
+### 创建文件上传会话
+```
+Usage:
+ dws minutes upload create [flags]
+Example:
+ dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘"
+Flags:
+ --file-name string 本地文件名 (必填)
+ --file-size string 文件大小,单位字节 (必填)
+ --title string 听记标题 (可选)
+ --template-id string 摘要模板 ID (可选)
+ --input-language string 语言,如 zh_CN / en_US (可选)
+ --enable-message-card string 是否推送消息卡片: true/false (可选)
+```
+
+三步上传的第 1 步:返回 sessionId 和预签名 URL,由调用方把本地音视频文件 HTTP PUT 到该 URL。
+
+### 完成上传并生成听记
+```
+Usage:
+ dws minutes upload complete [flags]
+Example:
+ dws minutes upload complete --session-id
+Flags:
+ --session-id string upload create 返回的 sessionId (必填)
+```
+
+三步上传的第 3 步:通知服务端文件已上传完毕,触发转写与 AI 处理,返回新的 taskUuid。
+
+### 取消上传
+```
+Usage:
+ dws minutes upload cancel [flags]
+Example:
+ dws minutes upload cancel --session-id
+Flags:
+ --session-id string upload create 返回的 sessionId (必填)
+```
+
+上传中途放弃或上游出错时释放会话,避免残留。
+
## 意图判断
用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选)
@@ -197,23 +272,23 @@ Flags:
用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选)
用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数
用户说"听记详情/听记信息" → `get info`
+用户说"批量看听记/一次查多篇" → `get batch`(`--ids` 传逗号分隔 taskUuid)
用户说"摘要/总结/会议纪要" → `get summary`
用户说"关键字/关键词" → `get keywords`
用户说"原文/转写/录音文字" → `get transcription`
用户说"会议待办/听记待办" → `get todos`
用户说"改听记标题/重命名听记" → `update title`
-用户说"发起听记/开始录音" → `record start`
-用户说"暂停听记/暂停录音" → `record pause`
-用户说"继续听记/恢复录音" → `record resume`
-用户说"结束听记/结束录音" → `record stop`
+用户说"改摘要/覆盖摘要/重写总结" → `update summary`
+用户说"全文替换/批量改转写里的错字/专有名词搞错了" → `replace-text`
+用户说"改发言人/把说话人1改成张三/认领发言人" → `speaker replace`
+用户说"加热词/让后续识别更准/把某个专业术语教给系统" → `hot-word add`
+用户说"生成思维导图/导图/脑图" → `mind-graph create`(异步),再 `mind-graph status` 轮询
+用户说"上传录音生成听记/把本地音视频变听记" → `upload create` → 本地 PUT → `upload complete`;放弃则 `upload cancel`
用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作
## 核心工作流
```bash
-# 0. 发起听记(开始录音)
-dws minutes record start --format json
-
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --next-token --format json
@@ -236,13 +311,28 @@ dws minutes get transcription --id --format json
# 4. 提取待办事项
dws minutes get todos --id --format json
-# 5. 修改标题
-dws minutes update title --id --title "新标题" --format json
+# 5. 批量补齐多个 taskUuid 的详情
+dws minutes get batch --ids ,, --format json
-# 6. 录音控制(基于 start 返回的 taskUuid)
-dws minutes record pause --id --format json
-dws minutes record resume --id --format json
-dws minutes record stop --id --format json
+# 6. 修改标题 / 覆盖摘要
+dws minutes update title --id --title "新标题" --format json
+dws minutes update summary --id --content "修订后的摘要 Markdown" --format json
+
+# 7. 修正转写错字 / 发言人标签 / 加热词
+dws minutes replace-text --id --search "小钉" --replace "DingTalk" --format json
+dws minutes speaker replace --id --from "说话人1" --to "张三" --format json
+dws minutes hot-word add --words "钉钉,悟空,DWS" --format json
+
+# 8. 异步生成思维导图(create 拿 jobId,status 轮询)
+dws minutes mind-graph create --id --format json
+dws minutes mind-graph status --id --format json
+
+# 9. 从本地音视频上传生成听记(三步走)
+dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘" --format json
+# → 拿到 sessionId 和预签名 URL,调用方自行 HTTP PUT 把文件推上去
+dws minutes upload complete --session-id --format json
+# 放弃上传
+dws minutes upload cancel --session-id --format json
```
## 上下文传递表
@@ -253,18 +343,23 @@ dws minutes record stop --id --format json
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
+| `upload create` | `sessionId`、预签名 URL | 本地 PUT 上传 + `upload complete` / `upload cancel` 的 --session-id |
+| `upload complete` | 新听记 `taskUuid` | 后续 get/update/mind-graph 的 --id |
+| `mind-graph create` | 异步 jobId(随 taskUuid 关联) | `mind-graph status` 的 --id 查进度与结果 |
## 注意事项
-- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参
-- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid`
-- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
+- `taskUuid` 是听记的唯一标识,所有 get/update/mind-graph/speaker/replace-text 操作均以此为入参
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
-- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
-- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
+- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`);三者均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
-- `get summary` 返回 AI 生成的结构化 Markdown 摘要
+- `get info` 是单条查询;`get batch` 是批量补齐 ID 列表(`--ids` 逗号分隔),用在"有一串 taskUuid,想一次拿标题/时长/参与人"的场景
+- `get summary` 返回 AI 生成的结构化 Markdown 摘要;`update summary` 会整体覆盖原摘要,不做增量合并
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序
-- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid
+- `replace-text` 是系统性纠错:命中即替换,整篇转写与摘要一起改,慎用模糊文本
+- `speaker replace` 只改"标签",不重跑说话人分离;`--target-uid` 可顺带把该说话人绑定到真实 userId
+- `hot-word add` 作用于**个人**热词库,只对本人后续的听记识别生效
+- `mind-graph create` 是异步:调用后立即返回,需要用 `mind-graph status --id ` 轮询到 ready 才能拿到图
+- 上传流程严格三步:`upload create`(拿 sessionId + 预签名 URL)→ 客户端自己 HTTP PUT 文件 → `upload complete`(触发转写);中途放弃必须用 `upload cancel` 释放会话,不要只丢 sessionId
## 自动化脚本
diff --git a/skills/references/products/oa.md b/skills/references/products/oa.md
new file mode 100644
index 00000000..ae544b11
--- /dev/null
+++ b/skills/references/products/oa.md
@@ -0,0 +1,199 @@
+# OA 审批 (oa) 命令参考
+
+钉钉 OA 审批:待办审批列表 / 查看详情 / 同意 / 拒绝 / 撤销 / 流程记录 / 表单模板。
+
+## 命令总览
+
+### 查询待我处理的审批实例
+```
+Usage:
+ dws oa approval list-pending [flags]
+Example:
+ dws oa approval list-pending
+ dws oa approval list-pending --page 1 --size 20
+ dws oa approval list-pending --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
+Flags:
+ --page string 页码 (默认 1)
+ --size string 每页数量 (默认 10)
+ --start string 提交开始时间 ISO-8601 (可选)
+ --end string 提交结束时间 ISO-8601 (可选)
+```
+
+"待办收件箱"视图:列出当前用户仍需动作的审批 `processInstanceId`。注意返回的是**实例 ID**,真正驱动 approve/reject 还需要用 `tasks` 再换成 `taskId`。
+
+### 查询我发起的审批实例
+```
+Usage:
+ dws oa approval list-initiated [flags]
+Example:
+ dws oa approval list-initiated
+ dws oa approval list-initiated --process-code --max-results 20
+ dws oa approval list-initiated --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --next-token
+Flags:
+ --process-code string 按指定表单/流程模板过滤 (可选)
+ --start string 发起开始时间 ISO-8601 (可选)
+ --end string 发起结束时间 ISO-8601 (可选)
+ --max-results string 每页条数 (可选)
+ --next-token string 分页 token (首页留空)
+```
+
+查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。
+
+### 查询可发起的审批表单模板
+```
+Usage:
+ dws oa approval list-forms [flags]
+Example:
+ dws oa approval list-forms
+ dws oa approval list-forms --cursor 0 --size 20
+Flags:
+ --cursor string 分页游标,首次传 0 (默认 0)
+ --size string 每页大小 (可选)
+```
+
+列出当前用户被授权发起的审批表单(如"请假"、"报销"),返回 `processCode`,后续用于提交新的申请或按模板筛选历史实例。
+
+### 获取审批实例详情
+```
+Usage:
+ dws oa approval detail [flags]
+Example:
+ dws oa approval detail --instance-id
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+```
+
+拉取单个实例的完整内容:表单字段、附件、当前状态、参与人。适合让 AI 读懂审批单再决定动作或生成摘要。
+
+### 查询审批实例的操作记录
+```
+Usage:
+ dws oa approval records [flags]
+Example:
+ dws oa approval records --instance-id
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+```
+
+列出实例的流转历史(谁在什么时候同意/加签/转交/评论),用于复盘或解释"这单卡在哪"。
+
+### 查询实例下待处理的任务
+```
+Usage:
+ dws oa approval tasks [flags]
+Example:
+ dws oa approval tasks --instance-id
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+```
+
+针对具体实例拿到当前用户名下的 `taskId`。approve / reject 要的是 `taskId`,不是 `processInstanceId`,这一步不能省。
+
+### 同意审批
+```
+Usage:
+ dws oa approval approve [flags]
+Example:
+ dws oa approval approve --instance-id --task-id
+ dws oa approval approve --instance-id --task-id --remark "同意,按流程推进"
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+ --task-id string 待办任务 taskId (必填),来自 approval tasks
+ --remark string 审批意见 (可选)
+```
+
+以当前用户身份同意某个审批任务。`--task-id` 必须是当前用户的待办 taskId,否则 MCP 会拒绝。
+
+### 拒绝审批
+```
+Usage:
+ dws oa approval reject [flags]
+Example:
+ dws oa approval reject --instance-id --task-id --remark "预算不足,下月再议"
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+ --task-id string 待办任务 taskId (必填),来自 approval tasks
+ --remark string 拒绝理由 (可选,但强烈建议填)
+```
+
+以当前用户身份驳回审批任务;拒绝后一般由 MCP 端决定流程是打回申请人还是整体终止。
+
+### 撤回已发起的审批
+```
+Usage:
+ dws oa approval revoke [flags]
+Example:
+ dws oa approval revoke --instance-id
+ dws oa approval revoke --instance-id --remark "金额填错,重新提交" --yes
+Flags:
+ --instance-id string 审批实例 processInstanceId (必填)
+ --remark string 撤回原因 (可选)
+```
+
+只能撤销**自己发起且尚未终态**的实例。命令标了 `isSensitive`,AI 调用前建议与用户再确认一次,可配合全局 `--yes` 跳过确认。
+
+## 意图判断
+
+用户说"我要审的单/待我处理的审批/审批收件箱" → `list-pending` 拿 `processInstanceId`
+用户说"这单我同意/批了它/通过" → `approval tasks` 换 `taskId` → `approval approve`
+用户说"这单不行/驳回/拒绝" → `approval tasks` 换 `taskId` → `approval reject --remark "<理由>"`
+用户说"这单详情/看看内容/这单在说什么" → `approval detail`
+用户说"这单卡在谁那/谁还没审/流程走到哪" → `approval records`
+用户说"我发起的审批/我的申请" → `list-initiated`(可用 `--process-code` 按模板筛)
+用户说"能发起哪些审批/有哪些表单" → `list-forms`
+用户说"撤回我的申请/我不提了" → `approval revoke`(仅限自己发起且未终态)
+
+关键区分: oa(钉钉 OA 审批流程) vs todo(个人待办) vs report(日志汇报)
+
+## 核心工作流
+
+```bash
+# 1. 看我有哪些待审的单 — 提取 processInstanceId
+dws oa approval list-pending --page 1 --size 20 --format json
+
+# 2. 看单子内容(表单字段 / 附件 / 当前状态)
+dws oa approval detail --instance-id --format json
+
+# 3. 想动作(同意/拒绝)前,先拿 taskId
+dws oa approval tasks --instance-id --format json
+
+# 4. 同意 / 拒绝(taskId 来自步骤 3)
+dws oa approval approve --instance-id