Merge remote-tracking branch 'upstream/main'

This commit is contained in:
wxianfeng
2026-04-24 14:48:43 +08:00
68 changed files with 8118 additions and 454 deletions
+1
View File
@@ -16,6 +16,7 @@ dws
test/cli/testdata/
tmp/
test/cli_compat/testdata/
/internal/compat/testdata/*
.gitignore
.worktrees/
+42
View File
@@ -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 <parent> <branch>` 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.
+21 -14
View File
@@ -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 "<your-userId>" # create a todo (replace <your-userId>)
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 <service> --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 <service> --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.
<details>
<summary>Coming soon</summary>
`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)
</details>
@@ -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
+22 -13
View File
@@ -185,12 +185,17 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-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 "<your-userId>" # 创建待办(请替换为真实 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 <service> --help` 查看子命令。
> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --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` 产品。
<details>
<summary>即将推出</summary>
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
</details>
@@ -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) — 版本历史与迁移说明
+323
View File
@@ -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. |
+41
View File
@@ -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
+3 -1
View File
@@ -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)
})
+83
View File
@@ -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)
}
+20 -6
View File
@@ -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
}
}
}
@@ -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)
})
}
}
+1 -1
View File
@@ -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")
+62
View File
@@ -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
}
+210 -22
View File
@@ -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
}
+111
View File
@@ -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")
}
}
+9 -4
View File
@@ -420,20 +420,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 {
+93
View File
@@ -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()
+33 -2
View File
@@ -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) {
+27 -26
View File
@@ -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)
}
}
+50
View File
@@ -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)
}
+14
View File
@@ -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{}
}
+319
View File
@@ -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")
}
}
+31 -15
View File
@@ -127,22 +127,38 @@ 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 == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching 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)
}
// Defensive reset: clear any stale credential state from previous login
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
// This ensures --device login works regardless of what app.json contains.
p.resetCredentialState()
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
const maxAttempts = 3
+41
View File
@@ -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)
}
+26 -15
View File
@@ -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
@@ -94,21 +104,22 @@ 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 == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching 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)
}
// Defensive reset: clear any stale credential state from previous login
// methods so we always re-fetch clientID from MCP. This ensures
// --force login works regardless of what app.json contains.
p.resetCredentialState()
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
// Find a free port for the callback server.
+40
View File
@@ -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)
}
+4 -1
View File
@@ -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)
})
+113 -18
View File
@@ -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
}
+199
View File
@@ -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()
+23
View File
@@ -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,
)
}
}
+512 -40
View File
@@ -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 <target>".
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 `--<paramName>` 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: <target>" 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.
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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 <target>" 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
}
+107
View File
@@ -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)
}
}
}
}
+447 -38
View File
@@ -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 [<placeholder>] 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
}
+65
View File
@@ -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()
+28 -3
View File
@@ -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) {
+125
View File
@@ -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")
}
}
+171
View File
@@ -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)
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
"bot",
tool,
params,
)
+51
View File
@@ -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)
}
})
}
}
+117
View File
@@ -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}
}
+108
View File
@@ -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()
+7
View File
@@ -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.
+5
View File
@@ -40,7 +40,12 @@ const (
// StorageDir returns the storage directory for a given service name on macOS.
// Uses ~/Library/Application Support/<service> 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)
+6 -1
View File
@@ -34,8 +34,13 @@ const (
)
// StorageDir returns the storage directory for a given service name.
// Follows XDG Base Directory Specification: ~/.local/share/<service>
// Follows XDG Base Directory Specification: ~/.local/share/<service>.
// 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)
+152 -8
View File
@@ -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 <target>" 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: <target>" 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))
+8 -3
View File
@@ -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
}
+302
View File
@@ -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
}
+221
View File
@@ -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<string> 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: " * <name>"
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)
}
}
+74
View File
@@ -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
}
+100
View File
@@ -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
}
+159
View File
@@ -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")
}
}
+54
View File
@@ -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
}
+44 -1
View File
@@ -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 (
+44
View File
@@ -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)
}
})
}
+55 -21
View File
@@ -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 <command-path> --help
# 例:dws calendar event list --help
# 2) 机读视图:JSON Schema + flag 别名映射 + 必填字段
dws schema # 列出所有产品及工具
dws schema <product>.<canonical_name> # 规范路径(如 calendar.list_suggested_event_times)
dws schema "<product> <group> <cli_name>" # CLI 路径(如 "calendar event list")
dws schema <path> --jq '.tool.flag_overlay' # 只看 flag 别名
dws schema <path> --jq '.tool.required' # 只看必填字段
```
**何时用哪条路径:**
- 只需看某个命令怎么调用 → `dws <cmd> --help`
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → `dws schema <path>`
- 参考文档和 `--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=<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表格字段类型规则
+380 -21
View File
@@ -215,11 +215,12 @@ Example:
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --name "新字段名"
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-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 <BASE_ID> --table-id <TABLE_ID> \
> ⚠️ `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。
### view (视图管理)
视图是同一张数据表的筛选/排序/分组/可见字段组合的备用呈现。record query 可以通过视图收敛查询范围。
#### 获取视图
```
Usage:
dws aitable view get [flags]
Example:
dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable view get --base-id <BASE_ID> --table-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 <BASE_ID> --table-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 <BASE_ID> --table-id <TABLE_ID> --view-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 <BASE_ID> --table-id <TABLE_ID> --view-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 <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 <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 <BASE_ID> --dashboard-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 <BASE_ID> --config '<JSON>'
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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --config '<JSON>'
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 <BASE_ID> --dashboard-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 <BASE_ID> --dashboard-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 <BASE_ID> --dashboard-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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --config '<JSON>'
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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --config '<JSON>'
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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-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 <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-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 <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
# 3) 写入/更新都用 --config JSON(名称、布局、图表类型都在里面)
dws aitable dashboard create --base-id <BASE_ID> --config '<JSON>' --format json
dws aitable chart update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --config '<JSON>' --format json
```
要点:
@@ -399,24 +726,20 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-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 <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 1) 先上传文件拿 importId
dws aitable import upload --base-id <BASE_ID> --file-name sales.xlsx --file-size 204800 --format json
# 把本地文件 PUT 到返回的 uploadUrl(过期前必须上传完成)
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
# 2) 触发导入
dws aitable import data --import-id <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 <BASE_ID> --task-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 <BASE_ID> --table-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` 筛选语法排错与使用规范(极易出错)
+37 -9
View File
@@ -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 <EVENT_ID> --title "新标题"
dws calendar event update --id <EVENT_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 <USER_ID_1>,<USER_ID_2> \
--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`
## 核心工作流
+31 -107
View File
@@ -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 <openconversation_id>
dws chat group members --id <openconversation_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 <openConversationId> --content "你好" --type text
dws chat message send-personal --open-id <openDingTalkId> --content "消息内容" --type text
dws chat message send-personal --id <openConversationId> --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 <openConversationId>
dws chat message info --open-id <openDingTalkId>
dws chat conversation-info --group <openConversationId>
dws chat conversation-info --open-dingtalk-id <openDingTalkId>
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 <nextCursor>
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 <robot-code> --group <openconversati
--keys <processQueryKey> --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 <openConversationId> --robot-code <robotCode> --format json
# Step 4: 机器人发消息
@@ -775,8 +700,8 @@ dws chat message send --group <openconversation_id> \
| `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 <openconversation_id> \
- `--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` 不需要指定单聊/群聊,返回结果自带会话类型标识
+6 -7
View File
@@ -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 <deptId> --format json
@@ -93,7 +93,6 @@ dws contact dept list-members --ids <deptId> --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 |
## 注意事项
+48
View File
@@ -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 只查**官方开发者文档**
+122 -7
View File
@@ -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 <FOLDER_ID>
dws doc file create --name "思维导图" --type amind --workspace <WS_ID>
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 <DOC_ID> --folder <TARGET_FOLDER_ID>
dws doc copy --node <DOC_ID> --workspace <TARGET_WS_ID>
dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder <FOLDER_ID>
Flags:
--node string 源文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录)
```
### 移动文档/文件
```
Usage:
dws doc move [flags]
Example:
dws doc move --node <DOC_ID> --folder <TARGET_FOLDER_ID>
dws doc move --node <DOC_ID> --workspace <TARGET_WS_ID>
Flags:
--node string 源文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录)
```
### 重命名文档/文件
```
Usage:
dws doc rename [flags]
Example:
dws doc rename --node <DOC_ID> --name "新名称"
dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --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 <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
dws doc comment create-inline --node <DOC_ID> --block-id <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 <DOC_ID> --block-id <BLOCK_ID> --yes
# 1. 查看文档的所有评论
dws doc comment list --node <DOC_ID> --format json
# 2. 在文档上创建评论
# 2. 在文档上创建全文评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 3. 创建评论并 @ 相关人
@@ -398,11 +483,34 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
# 再将 userId 传入 --mention
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
# 4. 对某段文字创建划词评论(需先 block list 拿 blockId 和字符偏移)
dws doc block list --node <DOC_ID> --format json
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 12 \
--content "这里的数据要复核" --selected-text "被选中的原文片段" --format json
# 5. 回复某条评论(commentKey 从 list 或 create 返回中获取)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 5. 用表情回复评论
# 6. 用表情回复评论
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# ── 工作流 8: 创建非文档类型文件 ──
# 创建表格 / 脑图 / 白板 / 多维表 / 演示文稿
dws doc file create --name "销售数据" --type axls --folder <FOLDER_ID> --format json
dws doc file create --name "需求脑图" --type amind --workspace <WS_ID> --format json
dws doc file create --name "Q1 立项会" --type appt --format json
# ── 工作流 9: 整理文档结构 (复制 / 移动 / 重命名) ──
# 1. 把模板复制到目标目录作为新工作件
dws doc copy --node <TEMPLATE_DOC_ID> --folder <TARGET_FOLDER_ID> --format json
# 2. 把文档从个人空间挪到团队知识库
dws doc move --node <DOC_ID> --workspace <WS_ID> --format json
# 3. 重命名
dws doc rename --node <DOC_ID> --name "项目周报 v2" --format json
```
## 上下文传递表
@@ -418,8 +526,11 @@ dws doc comment reply --node <DOC_ID> --comment-key <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 开始)
## 自动化脚本
+177
View File
@@ -0,0 +1,177 @@
# 钉盘 (drive) 命令参考
钉盘 = DingTalk Drive,用于云端文件存储 / 上传 / 下载 / 目录管理。不是在线文档编辑;要编辑文档请用 [doc](./doc.md)。
## 命令总览
### 列出钉盘目录
```
Usage:
dws drive list [flags]
Example:
dws drive list
dws drive list --parent-id <FOLDER_ID>
dws drive list --parent-id <FOLDER_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 <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 <PARENT_FOLDER_ID>
Flags:
--name string 文件夹名称 (必填)
--parent-id string 父目录 ID (不传则建在根目录)
--space-id string 钉盘空间 ID (一般无需指定)
```
### 获取下载临时链接
```
Usage:
dws drive download [flags]
Example:
dws drive download --file-id <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 <FOLDER_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 <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 <FOLDER_ID> --format json
# 3. 看单个文件的元信息
dws drive info --file-id <FILE_ID> --format json
# ── 工作流 2: 上传本地文件到钉盘 (三步不能省) ──
# Step 1: 拿上传凭证
dws drive upload-info --file-name "report.pdf" --file-size 102400 --parent-id <FOLDER_ID> --format json
# → 返回: resourceUrl (预签名 URL), headers, uploadId
# Step 2: Agent 自己发 HTTP PUT 把文件二进制推到 resourceUrl
# 请求头必须携带返回的 headers 全部键值对;期望 HTTP 200
curl -X PUT -T ./report.pdf -H "<header-from-step-1>: <value>" "<resourceUrl>"
# Step 3: 提交入库
dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id <UPLOAD_ID> \
--parent-id <FOLDER_ID> --format json
# → 返回: dentryUuid (= 后续 download/info 用的 fileId)
# ── 工作流 3: 下载钉盘文件到本地 (两步) ──
# Step 1: 拿临时下载 URL
dws drive download --file-id <FILE_ID> --format json
# → 返回: resourceUrl (带签名的临时下载 URL), expirationSeconds
# Step 2: Agent 自己发 HTTP GET 下载二进制
curl -o ./report.pdf "<resourceUrl>"
# ── 工作流 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 链接语法)
+138 -43
View File
@@ -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 <sessionId>
dws minutes update summary --id <taskUuid> --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 <taskUuid>
dws minutes record pause --id <taskUuid> --session-id <sessionId>
dws minutes replace-text --id <taskUuid> --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 <taskUuid>
dws minutes record resume --id <taskUuid> --session-id <sessionId>
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三"
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三" --target-uid <userId>
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 <taskUuid>
dws minutes record stop --id <taskUuid> --session-id <sessionId>
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 <taskUuid>
Flags:
--id string 听记 taskUuid (必填)
```
异步任务:提交后返回 jobId,需要用 `mind-graph status` 轮询结果。
### 查询思维导图状态
```
Usage:
dws minutes mind-graph status [flags]
Example:
dws minutes mind-graph status --id <taskUuid>
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 <sessionId>
Flags:
--session-id string upload create 返回的 sessionId (必填)
```
三步上传的第 3 步:通知服务端文件已上传完毕,触发转写与 AI 处理,返回新的 taskUuid。
### 取消上传
```
Usage:
dws minutes upload cancel [flags]
Example:
dws minutes upload cancel --session-id <sessionId>
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 <nextToken> --format json
@@ -236,13 +311,28 @@ dws minutes get transcription --id <taskUuid> --format json
# 4. 提取待办事项
dws minutes get todos --id <taskUuid> --format json
# 5. 修改标题
dws minutes update title --id <taskUuid> --title "新标题" --format json
# 5. 批量补齐多个 taskUuid 的详情
dws minutes get batch --ids <taskUuid1>,<taskUuid2>,<taskUuid3> --format json
# 6. 录音控制(基于 start 返回的 taskUuid)
dws minutes record pause --id <taskUuid> --format json
dws minutes record resume --id <taskUuid> --format json
dws minutes record stop --id <taskUuid> --format json
# 6. 修改标题 / 覆盖摘要
dws minutes update title --id <taskUuid> --title "新标题" --format json
dws minutes update summary --id <taskUuid> --content "修订后的摘要 Markdown" --format json
# 7. 修正转写错字 / 发言人标签 / 加热词
dws minutes replace-text --id <taskUuid> --search "小钉" --replace "DingTalk" --format json
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三" --format json
dws minutes hot-word add --words "钉钉,悟空,DWS" --format json
# 8. 异步生成思维导图(create 拿 jobId,status 轮询)
dws minutes mind-graph create --id <taskUuid> --format json
dws minutes mind-graph status --id <taskUuid> --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 <sessionId> --format json
# 放弃上传
dws minutes upload cancel --session-id <sessionId> --format json
```
## 上下文传递表
@@ -253,18 +343,23 @@ dws minutes record stop --id <taskUuid> --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>`),直接从路径末段提取 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 <taskUuid>` 轮询到 ready 才能拿到图
- 上传流程严格三步:`upload create`(拿 sessionId + 预签名 URL)→ 客户端自己 HTTP PUT 文件 → `upload complete`(触发转写);中途放弃必须用 `upload cancel` 释放会话,不要只丢 sessionId
## 自动化脚本
+199
View File
@@ -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 <processCode> --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 <nextToken>
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 <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
拉取单个实例的完整内容:表单字段、附件、当前状态、参与人。适合让 AI 读懂审批单再决定动作或生成摘要。
### 查询审批实例的操作记录
```
Usage:
dws oa approval records [flags]
Example:
dws oa approval records --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
列出实例的流转历史(谁在什么时候同意/加签/转交/评论),用于复盘或解释"这单卡在哪"。
### 查询实例下待处理的任务
```
Usage:
dws oa approval tasks [flags]
Example:
dws oa approval tasks --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
针对具体实例拿到当前用户名下的 `taskId`。approve / reject 要的是 `taskId`,不是 `processInstanceId`,这一步不能省。
### 同意审批
```
Usage:
dws oa approval approve [flags]
Example:
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId>
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId> --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 <processInstanceId> --task-id <taskId> --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 <processInstanceId>
dws oa approval revoke --instance-id <processInstanceId> --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 <processInstanceId> --format json
# 3. 想动作(同意/拒绝)前,先拿 taskId
dws oa approval tasks --instance-id <processInstanceId> --format json
# 4. 同意 / 拒绝(taskId 来自步骤 3)
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId> --remark "同意" --format json
dws oa approval reject --instance-id <processInstanceId> --task-id <taskId> --remark "预算不足" --format json
# 5. 查看流程记录 — 谁何时做了什么
dws oa approval records --instance-id <processInstanceId> --format json
# 6. 查我发起的审批 / 按模板过滤
dws oa approval list-initiated --format json
dws oa approval list-initiated --process-code <processCode> --format json
# 7. 能发起哪些表单
dws oa approval list-forms --cursor 0 --size 20 --format json
# 8. 撤回我发起的单
dws oa approval revoke --instance-id <processInstanceId> --remark "填错了" --yes --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list-pending` | `processInstanceId`、`nextToken` | detail / tasks / records 的 --instance-id |
| `list-initiated` | `processInstanceId`、`nextToken` | detail / records / revoke 的 --instance-id |
| `list-forms` | `processCode` | `list-initiated --process-code` 按模板过滤,或后续发起新审批 |
| `approval tasks` | `taskId` | approve / reject 的 --task-id |
| `approval detail` | 表单字段、参与人、当前状态 | 供 AI 决策是否批准、生成摘要 |
## 注意事项
- `processInstanceId`(实例 ID)与 `taskId`(待办任务 ID)**不是一回事**,不能混用:
- `list-pending` / `list-initiated` / `detail` / `records` / `revoke` 用 `processInstanceId`
- `approve` / `reject` 用 `taskId`,必须先 `approval tasks --instance-id <processInstanceId>` 换一次
- `list-pending` 是"需要我动作"视图;同一个实例如果流转到下一节点就会从这里消失
- `list-initiated` 是"我发起的"视图;和 `list-pending` 是两套语义,不要互相替代
- `--start` / `--end` 使用 ISO-8601 格式,内部会自动转毫秒时间戳
- `revoke` 只能撤销**当前用户发起且未完结**的实例;已流转结束、已作废、或不是自己发起的都会失败
- `approve` / `reject` 的 `--remark` 可选但强烈建议填,尤其是 `reject`——审批流下游会把理由展示给申请人
- 所有敏感动作(`approve` / `reject` / `revoke`)在 AI 调用时应与用户二次确认,可用全局 `--yes` 跳过确认
- `list-forms` 返回的 `processCode` 是未来发起新审批、或筛选历史实例的钩子;目前本命令集只覆盖审批的"处理侧",发起新审批需走业务方自己的流程
+4 -2
View File
@@ -84,6 +84,8 @@ Flags:
--report-id string 日志 ID (必填)
```
返回该日志的互动聚合数据:浏览数 (views)、点赞数 (likes)、评论数 (comments) 等,用于衡量一篇日志的阅读与反馈情况。
### 查询当前人创建的日志列表
```
Usage:
@@ -106,7 +108,7 @@ Flags:
用户说"查日志/看日报" → `list` 获取列表,再 `detail`
用户说"写日报/提交周报/发日志/填日志" → 先 `template list` / `template detail` 取 `templateId` 与各控件 `key`/`sort`/类型,拼 `--contents` JSON,再 `create`
用户说"日志统计/已读统计" → `stats`
用户说"日志数据/互动情况/多少人看了/点赞评论" → `stats`
用户说"有什么日志模版" → `template list` 或 `template detail`
用户说"我发过的日志/我创建的日志" → `sent`
@@ -133,7 +135,7 @@ dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+0
# 4. 查看日志详情
dws report detail --report-id <reportId> --format json
# 5. 查看日志统计(已读/未读)
# 5. 查看日志统计(浏览/点赞/评论聚合)
dws report stats --report-id <reportId> --format json
# 6. 查看当前人创建的日志列表
+9 -4
View File
@@ -9,11 +9,15 @@ Usage:
Example:
dws todo task create --title "修复线上Bug" --executors <USER_ID_1>,<USER_ID_2> --priority 40
dws todo task create --title "提交报告" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00"
dws todo task create --title "每日站会" --executors <USER_ID> \
--due "2026-03-20T10:00:00+08:00" \
--recurrence $'DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1'
Flags:
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--title string 待办标题 (必填)
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--recurrence string 循环规则 RFC 5545 (DTSTART+RRULE,仅在同时设置 --due 时生效)
--title string 待办标题 (必填)
```
### 查询待办列表
@@ -122,6 +126,7 @@ dws todo task delete --task-id <taskId> --yes --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 截止时间使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- `--recurrence` 为 RFC 5545 循环规则(`DTSTART:...\nRRULE:FREQ=DAILY;INTERVAL=1` 这种格式),必须与 `--due` 同时设置才生效
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
+2
View File
@@ -35,6 +35,8 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
"REWIND_" + "MESSAGE_ID",
"MergeWukong" + "MCPHeaders",
"buildMode ==" + " \"real\"",
"wukong/" + "discovery",
"dingi8fo" + "prfi3jynjjlu",
}
var matches []string