diff --git a/.gitignore b/.gitignore index 125c6dd2..98abb710 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dws test/cli/testdata/ tmp/ test/cli_compat/testdata/ +/internal/compat/testdata/* .gitignore .worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b580821..d345be68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file. The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/). +## [1.0.15] - 2026-04-23 + +Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws ` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands. + +### Added + +- **`internal/compat` subcommand merging via `attachOrMerge`** — when two or more server entries attach to the same parent (e.g. `parent: "chat"`) and their `cli.command` collides with an existing subcommand in the parent's tree, the new subcommand's children are merged recursively into the existing one instead of creating a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the "double `group` / `message` rows in `dws chat --help`" symptom when bot capabilities are distributed across `chat.group.members` and `chat.message`. +- **`docs/command-index.md`** — a single, English, auto-generated listing of every runtime command the `dws` CLI exposes under the pre environment (159 total). Each entry carries a description and a "when to use" column aimed at AI agents. Replaces the earlier `command-index.pre.*` / `command-index.full.*` ad-hoc snapshots. + +### Changed + +- **README Key Services table** (`README.md` + `README_zh.md`) fully synced to the shipped command surface: + - `Chat`: 20 → **23** (bot capabilities merged in; new `list-all` / `list-focused` / `list-unread-conversations` / `conversation-info` exposed) + - `Calendar`: 13 → **14** + - `AI Tables`: 37 → **41** (chart / dashboard public-share config rows) + - `Doc`: 16 → **21** (comment subtree + `file create`) + - `Minutes`: 22 → **19** (single-tool `record`, `list query`, `list-by-keyword-range` pruned) + - New `Drive` row (6 commands) — promoted out of "Coming soon" + - `Workbench` row and standalone `Bot` row removed + - Total revised to **159 commands across 13 products** +- **Quick Start** expanded to 7 examples covering `doc`, `minutes`, `drive` in addition to `contact`, `calendar`, `todo` +- **Coming soon** trimmed to 5: `mail`, `conference`, `aiapp`, `live`, `wiki` +- **Reference & Docs** section now leads with a pointer to the new `docs/command-index.md` +- **Flag naming cleanup** — CLI flags across chat, calendar, drive, minutes, contact, and devdoc have been standardised so the names users type match the product-skill documentation. Notable flags: + - `dws contact user search` / `dws contact dept search` / `dws devdoc article search` now take `--query` (previously `--keyword`) + - `dws chat message list` / `dws chat message search` / `dws chat message list-mentions` / `dws chat conversation-info` / `dws chat message send` now take `--group` for the target conversation (previously `--id`) and `--open-dingtalk-id` (previously `--open-id`) + - `dws chat message list-by-sender` now takes `--sender-user-id` / `--sender-open-dingtalk-id` (previously `--user` / `--open-id`) + - `dws chat message list-topic-replies` now takes `--group` / `--topic-id` / `--limit` / `--time` (previously `--id` / `--topic` / `--size` / `--start`) + - `dws chat search-common` now takes `--match-mode` (previously `--mode`) + - `dws drive list` now takes `--max` / `--thumbnail` (previously `--max-results` / `--with-thumbnail`) + - `dws calendar event suggest` now takes `--users` / `--duration` / `--timezone` (previously `--attendee-user-ids` / `--duration-minutes` / `--time-zone`) + - `dws minutes list mine` / `dws minutes list shared` now take `--max` (previously `--max-results`) and gain `--query` / `--start` / `--end` + - `dws minutes list all` no longer exposes the legacy `--__scope__` internal alias +- **Flag coverage additions** — `dws calendar event create` / `update` gain `--attendees`, `--open-dingtalk-ids`, `--timezone`; `dws chat message send` gains file-message flags (`--dentry-id`, `--file-name`, `--file-size`, `--file-type`, `--media-id`, `--msg-type`, `--space-id`) plus `--open-dingtalk-id` / `--user`; `dws chat message list` gains `--open-dingtalk-id` / `--user`; `dws aitable table delete` gains `--reason`; `dws calendar participant add` gains `--optional`; `dws todo task create` gains `--recurrence`. + +### Tests + +- 3 new unit tests in `internal/compat/dynamic_commands_test.go`: + - `TestBuildDynamicCommands_ParentMergeSameName` — two servers with identical `command` + `parent` collapse into a single merged subcommand + - `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`) + - `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates + ## [1.0.13] - 2026-04-22 IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle. diff --git a/README.md b/README.md index c96a9dbc..9d24f3a7 100644 --- a/README.md +++ b/README.md @@ -185,12 +185,17 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs ## Quick Start ```bash -dws contact user search --keyword "engineering" # search contacts -dws calendar event list # list calendar events +dws contact user search --query "engineering" # search contacts +dws calendar event list # list today's calendar events +dws doc search --query "quarterly" # search DingTalk Docs +dws minutes list mine # list AI meeting notes I created +dws drive list # list DingTalk drive files dws todo task create --title "Quarterly report" --executors "" # create a todo (replace ) dws todo task list --dry-run # preview without executing ``` +> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all 159 commands with descriptions and when-to-use guidance. + ## Using with Agents dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent: @@ -350,26 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | Service | Command | Commands | Subcommands | Description | |---------|---------|:--------:|-------------|-------------| | Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile | -| Chat / IM | `chat` (alias `im`) | 20 | `message` `group` `search` `list-top-conversations` | User-identity send (group / 1-on-1 / open-dingtalk-id), Markdown + image, @mentions; read & search conversations (list, list-all, topic replies, by-sender, mentions, focused, unread, search, info, top / common groups); group CRUD + member management | -| Bot | `chat bot` | 7 | `bot` `group` `message` `search` `create` `search-groups` | Bot create / search, search bot groups; bot-identity group & batch-1:1 messaging, Webhook, message recall; add bot to group | -| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management | +| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup | +| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management | | Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete | -| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list | +| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records | | Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules | -| Ding | `ding` | 2 | `message` | Send/recall DING messages | +| Ding | `ding` | 2 | `message` | Send / recall DING messages | | Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics | -| AITable | `aitable` | 37 | `base` `table` `record` `field` `attachment` `template` `chart` `dashboard` `export` `import` `view` | Full CRUD for bases/tables/records/fields; charts/dashboards; data import/export; views; templates | -| Doc | `doc` | 16 | `search` `list` `info` `read` `create` `update` `upload` `download` `folder` `block` `comment` | Search, read, create/update documents; block-level editing; file upload/download; comments | -| Minutes | `minutes` | 22 | `list` `get` `update` `record` `hot-word` `mind-graph` `replace-text` `speaker` `upload` | List/search AI meeting transcripts; summaries, transcriptions, todos, mind-maps; recording control; speaker management, hot-words, file upload | -| Workbench | `workbench` | 2 | `app` | Batch query app details | -| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes | +| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates | +| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download | +| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload | +| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session | +| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation | -> 152 commands across 14 products. Run `dws --help` for the full list, or `dws --help` for subcommands. +> **159 commands across 13 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws --help` for subcommands. + +> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
Coming soon -`mail` (email) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace) +`mail` (email) · `conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
@@ -418,6 +424,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ ## Reference & Docs +- [Command Index](./docs/command-index.md) — every runtime command (159 total) with description and when-to-use guidance - [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion - [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer - [Changelog](./CHANGELOG.md) — release history and migration notes diff --git a/README_zh.md b/README_zh.md index ed8ce87c..81facb79 100644 --- a/README_zh.md +++ b/README_zh.md @@ -185,12 +185,17 @@ dws auth login --client-id --client-secret ## 快速开始 ```bash -dws contact user search --keyword "悟空" # 搜索联系人 -dws calendar event list # 查看日历日程 +dws contact user search --query "悟空" # 搜索联系人 +dws calendar event list # 查看今天的日程 +dws doc search --query "季度" # 搜索钉钉文档 +dws minutes list mine # 列出我创建的 AI 听记 +dws drive list # 列出钉盘文件 dws todo task create --title "季度汇报" --executors "" # 创建待办(请替换为真实 userId) dws todo task list --dry-run # 预览操作但不执行 ``` +> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部 159 条命令,带描述和使用场景。 + ## 在 Agent 中使用 dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境: @@ -350,24 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | 服务 | 命令 | 命令数 | 子命令 | 描述 | |------|------|:------:|--------|------| | 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 | -| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook | -| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 | -| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 | +| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 | +| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 | | 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 | -| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 | +| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 | | 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 | -| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 | -| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 | -| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 | -| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 | -| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 | +| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 | +| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 | +| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 | +| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 | +| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 | +| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 | +| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 | -> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。 +> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws --help` 查看子命令。 + +> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
即将推出 -`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场) +`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
@@ -418,6 +426,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ ## 参考与文档 +- [命令索引](./docs/command-index.md) — 159 条运行时命令,带描述与使用场景 - [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全 - [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层 - [更新日志](./CHANGELOG.md) — 版本历史与迁移说明 diff --git a/docs/command-index.md b/docs/command-index.md new file mode 100644 index 00000000..dfd964fc --- /dev/null +++ b/docs/command-index.md @@ -0,0 +1,323 @@ +# dws Command Index + +Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration. + +- **Source**: `dws-wukong/envelope/channel/open/pre/config.json` +- **Products**: 13 +- **Total commands**: 159 +- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime. + +> Auto-generated. Edit `pre/config.json`, not this file. + +## Global flags + +Every command inherits these flags (documented here once, not repeated per command): + +| Flag | Purpose | +|---|---| +| `--client-id` | Override OAuth client ID (DingTalk AppKey) | +| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) | +| `--debug` | Enable debug logging | +| `--dry-run` | Preview the request without executing | +| `--fields` | Comma-separated output field projection | +| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) | +| `--jq` | jq expression applied to JSON output | +| `--mock` | Return mock data (developer aid) | +| `-o, --output` | Write output to a file | +| `--timeout` | HTTP request timeout in seconds (default 30) | +| `--token` | Override the configured API token | +| `-v, --verbose` | Verbose logging | +| `-y, --yes` | Skip confirmation prompts (AI-agent mode) | + +## Contents + +- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands +- [`dws attendance` — Attendance](#dws-attendance) · 4 commands +- [`dws calendar` — Calendar](#dws-calendar) · 14 commands +- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands +- [`dws contact` — Contact Directory](#dws-contact) · 6 commands +- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands +- [`dws ding` — DING Messages](#dws-ding) · 2 commands +- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands +- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands +- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands +- [`dws oa` — OA Approval](#dws-oa) · 9 commands +- [`dws report` — Reports](#dws-report) · 7 commands +- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands + +## `dws aitable` — AI Tables + +_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._ + +**41 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. | +| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. | +| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. | +| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. | +| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. | +| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. | +| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. | +| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. | +| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. | +| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. | +| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. | +| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. | +| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. | +| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. | +| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. | +| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. | +| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. | +| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. | +| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. | +| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. | +| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. | +| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. | +| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. | +| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. | +| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. | +| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. | +| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. | +| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. | +| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. | +| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. | +| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. | +| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. | +| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. | +| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. | +| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. | +| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. | +| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. | +| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. | +| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. | +| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. | +| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. | + +## `dws attendance` — Attendance + +_Attendance check-in records, shifts, and aggregate statistics._ + +**4 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. | +| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. | +| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. | +| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. | + +## `dws calendar` — Calendar + +_Calendar events, participants, meeting rooms, and busy-status queries._ + +**14 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. | +| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. | +| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. | +| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. | +| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. | +| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. | +| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. | +| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. | +| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. | +| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. | +| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. | +| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. | +| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. | +| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. | + +## `dws chat` — Group Chat / IM + +_Group chats, conversations, messages, and robot/webhook integrations._ + +**23 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. | +| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. | +| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. | +| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. | +| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. | +| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. | +| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. | +| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. | +| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. | +| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. | +| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. | +| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. | +| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. | +| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. | +| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. | +| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. | +| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. | +| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. | +| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. | +| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. | +| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. | +| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. | +| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. | + +## `dws contact` — Contact Directory + +_Users, departments, and directory lookups._ + +**6 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. | +| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. | +| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. | +| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). | +| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. | +| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. | + +## `dws devdoc` — Open Platform Docs + +_Search the DingTalk Open Platform documentation._ + +**1 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. | + +## `dws ding` — DING Messages + +_Send and recall DING messages (priority notifications)._ + +**2 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. | +| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. | + +## `dws doc` — DingTalk Doc + +_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._ + +**21 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. | +| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. | +| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. | +| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. | +| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. | +| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. | +| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. | +| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. | +| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. | +| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. | +| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. | +| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. | +| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. | +| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. | +| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. | +| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. | +| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. | +| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. | +| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. | +| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. | +| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. | + +## `dws drive` — DingTalk Drive + +_DingTalk Drive file and folder management._ + +**6 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. | +| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. | +| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. | +| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. | +| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. | +| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. | + +## `dws minutes` — AI Minutes + +_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._ + +**19 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. | +| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. | +| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. | +| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. | +| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. | +| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. | +| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. | +| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. | +| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. | +| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. | +| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. | +| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. | +| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. | +| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. | +| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. | +| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. | +| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. | +| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. | +| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. | + +## `dws oa` — OA Approval + +_OA approval workflows: list, approve, reject, revoke, records._ + +**9 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. | +| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. | +| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. | +| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. | +| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. | +| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. | +| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. | +| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. | +| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. | + +## `dws report` — Reports + +_DingTalk Report feature: templates, entries, and statistics._ + +**7 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. | +| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. | +| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). | +| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. | +| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. | +| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. | +| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. | + +## `dws todo` — Todo Tasks + +_Personal todo task management._ + +**6 commands** + +| Command | Description | When to use | +|---|---|---| +| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. | +| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. | +| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. | +| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. | +| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. | +| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. | + diff --git a/docs/reference.md b/docs/reference.md index 87bb9fe8..2c37b8e0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -32,6 +32,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认) dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent) dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应 +dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计) ``` ## Dry Run / 试运行 @@ -46,6 +47,46 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但 dws contact user search --keyword "Alice" -o result.json ``` +## Schema Introspection / Schema 查询 + +`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。 + +### 路径写法 + +```bash +dws schema # 列出所有产品 + 工具名 +dws schema ding.send_ding_message # canonical: product.rpc_name +dws schema ding.message.send # CLI 点路径: product.group.cli_name +dws schema "ding message send" # CLI 空格路径(同上) +dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义) +dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服) +``` + +Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。 + +### 单工具输出字段 + +| 字段 | 说明 | +|------|------| +| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` | +| `group` | CLI 父级 group 路径(dot-separated) | +| `title` / `description` | 工具名/说明(overlay 优先) | +| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required | +| `output_schema` | MCP 输出 Schema(上游下发时才有) | +| `sensitive` | 敏感写操作,需 `--yes` 确认 | +| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 | +| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` | + +**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。 + +### 筛选输出 + +```bash +dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay +dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数 +dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示 +``` + ## Shell Completion / 自动补全 ```bash diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index cdf118c8..895c919c 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -26,7 +26,9 @@ import ( ) func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { - // Cleanup keychain after test + // Isolate keychain storage to a per-test directory so the saved + // token can't leak into other test packages running in parallel. + t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Cleanup(func() { _ = keychain.Remove(keychain.Service, keychain.AccountToken) }) diff --git a/internal/app/auth_refresh_retry.go b/internal/app/auth_refresh_retry.go new file mode 100644 index 00000000..d9462f9a --- /dev/null +++ b/internal/app/auth_refresh_retry.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry" +) + +// authRetryingKey marks a context that has already attempted one +// AuthRefreshRequired-driven retry of the current invocation. The runner uses +// this to refuse a second refresh+retry pass and surface the original cause +// to the user instead. +type authRetryingKeyType struct{} + +var authRetryingKey = authRetryingKeyType{} + +// IsAuthRetrying reports whether the current context is already inside an +// AuthRefreshRequired retry. Mirrors IsPatRetrying. +func IsAuthRetrying(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(authRetryingKey).(bool) + return v +} + +// withAuthRetrying returns a child context flagged as "already retried once" +// so the runner does not enter an infinite refresh loop if the second attempt +// also returns AuthRefreshRequired. +func withAuthRetrying(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, authRetryingKey, true) +} + +// handleAuthRefreshRequired performs a one-shot ForceRefresh using the active +// configDir and re-runs the invocation through the supplied runner. It must +// only be called when the runner has observed an *authretry.AuthRefreshRequired +// from an edition hook (ClassifyToolResult / OnAuthError). +// +// Behaviour rules — all three matter for safety: +// 1. If the context is already flagged via IsAuthRetrying, this returns +// refresh.Cause unchanged. No further refresh attempts, no recursion. +// 2. If ForceRefresh fails (e.g. refresh_token also expired), this returns +// refresh.Cause so the user sees the original auth diagnostic, not an +// internal "force refresh failed" message. +// 3. On successful refresh, this resets the per-process token cache and +// re-runs the invocation with withAuthRetrying applied so a second +// refresh request from the overlay degrades gracefully to "show the +// original error". +func handleAuthRefreshRequired( + ctx context.Context, + r executor.Runner, + invocation executor.Invocation, + refresh *authretry.AuthRefreshRequired, +) (executor.Result, error) { + if refresh == nil { + return executor.Result{}, nil + } + if IsAuthRetrying(ctx) { + return executor.Result{}, refresh.Cause + } + if _, err := ForceRefreshAccessToken(ctx, defaultConfigDir()); err != nil { + return executor.Result{}, refresh.Cause + } + ResetRuntimeTokenCache() + return r.Run(withAuthRetrying(ctx), invocation) +} diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index 33c28ac5..b95a772b 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -80,11 +80,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) { toolEndpoints[toolName] = endpoint } } - for toolName := range server.CLI.ToolOverrides { + for toolName, override := range server.CLI.ToolOverrides { toolName = strings.TrimSpace(toolName) - if toolName != "" { - toolEndpoints[toolName] = endpoint + if toolName == "" { + continue } + // Leaves with serverOverride are routed to a different server's + // endpoint (e.g. chat's "search_my_robots" → bot). Registering + // them here would overwrite the real owner's tool → endpoint + // mapping and send the invocation to the wrong MCP URL. + if strings.TrimSpace(override.ServerOverride) != "" { + continue + } + toolEndpoints[toolName] = endpoint } } } @@ -208,11 +216,17 @@ func AppendDynamicServer(server market.ServerDescriptor) { dynamicToolEndpoints[toolName] = endpoint } } - for toolName := range server.CLI.ToolOverrides { + for toolName, override := range server.CLI.ToolOverrides { toolName = strings.TrimSpace(toolName) - if toolName != "" { - dynamicToolEndpoints[toolName] = endpoint + if toolName == "" { + continue } + // Leaves with serverOverride are routed to a different server's + // endpoint; skip to avoid overwriting the real owner's mapping. + if strings.TrimSpace(override.ServerOverride) != "" { + continue + } + dynamicToolEndpoints[toolName] = endpoint } } } diff --git a/internal/app/direct_runtime_override_test.go b/internal/app/direct_runtime_override_test.go new file mode 100644 index 00000000..6d3c940a --- /dev/null +++ b/internal/app/direct_runtime_override_test.go @@ -0,0 +1,183 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" +) + +// Regression for the chat/bot tool routing bug: when the `chat` envelope +// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`, +// `send_message_by_custom_robot`), those tool names must NOT be registered +// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the +// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL +// even when the invocation's CanonicalProduct is "bot", causing the Portal to +// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such +// tool. +// +// Owner (bot envelope) still registers the tool (no serverOverride on the bot +// side), so product-level and tool-level lookups both resolve correctly. + +const ( + testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1" + testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74" +) + +// botDescriptor returns a minimal `bot` server descriptor that owns the +// `search_my_robots` + `send_message_by_custom_robot` tools (no +// serverOverride — bot is the real owner). +func botDescriptor() market.ServerDescriptor { + return market.ServerDescriptor{ + Endpoint: testBotEndpoint, + CLI: market.CLIOverlay{ + ID: "bot", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_my_robots": {CLIName: "search"}, + "send_message_by_custom_robot": {CLIName: "send-by-webhook"}, + "add_robot_to_group": {CLIName: "add-bot"}, + }, + }, + } +} + +// chatDescriptor returns a minimal `chat` server descriptor whose +// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a +// chat-native tool (`search_groups_by_keyword`) that must remain routed to +// chat's endpoint. +func chatDescriptor() market.ServerDescriptor { + return market.ServerDescriptor{ + Endpoint: testChatEndpoint, + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_groups_by_keyword": {CLIName: "search"}, + "search_my_robots": { + CLIName: "search", + ServerOverride: "bot", + }, + "send_message_by_custom_robot": { + CLIName: "send-by-webhook", + ServerOverride: "bot", + }, + "add_robot_to_group": { + CLIName: "add-bot", + ServerOverride: "bot", + }, + }, + }, + } +} + +// withCleanDynamicRegistry snapshots and restores the package-level dynamic +// registries so parallel/other tests aren't affected by this case's mutations. +func withCleanDynamicRegistry(t *testing.T) { + t.Helper() + dynamicMu.Lock() + prev := struct { + endpoints map[string]string + products map[string]bool + aliases map[string]string + toolEndpoints map[string]string + }{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints} + dynamicEndpoints = nil + dynamicProducts = nil + dynamicAliases = nil + dynamicToolEndpoints = nil + dynamicMu.Unlock() + t.Cleanup(func() { + dynamicMu.Lock() + dynamicEndpoints = prev.endpoints + dynamicProducts = prev.products + dynamicAliases = prev.aliases + dynamicToolEndpoints = prev.toolEndpoints + dynamicMu.Unlock() + }) +} + +func assertEndpoint(t *testing.T, productID, toolName, want string) { + t.Helper() + got, ok := directRuntimeEndpoint(productID, toolName) + if !ok { + t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName) + } + if got != want { + t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want) + } +} + +// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that +// chat's serverOverride entries cannot steal bot-owned tool routes, regardless +// of registration order. +func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) { + tests := []struct { + name string + servers []market.ServerDescriptor + }{ + { + name: "bot first, chat second", + servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()}, + }, + { + name: "chat first, bot second", + servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withCleanDynamicRegistry(t) + SetDynamicServers(tc.servers) + + // Bot-owned tools must route to bot's endpoint even though chat + // declares toolOverrides for them (with serverOverride="bot"). + assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint) + assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint) + assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint) + + // Chat-native tools must still route to chat. + assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint) + + // Product-level fallback for bot (no tool name) must also return + // bot's endpoint. + assertEndpoint(t, "bot", "", testBotEndpoint) + }) + } +} + +// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises +// the plugin-injection path (`AppendDynamicServer`) which has the same +// `toolOverrides` registration loop as `SetDynamicServers`. Chat's +// serverOverride entries must not overwrite bot's tool → endpoint mapping. +func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) { + orders := [][]market.ServerDescriptor{ + {botDescriptor(), chatDescriptor()}, + {chatDescriptor(), botDescriptor()}, + } + + for _, servers := range orders { + t.Run("", func(t *testing.T) { + withCleanDynamicRegistry(t) + for _, s := range servers { + AppendDynamicServer(s) + } + + assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint) + assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint) + assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint) + }) + } +} diff --git a/internal/app/flags.go b/internal/app/flags.go index c9a15164..df3de9f4 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) { cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志") cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行") cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)") - cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw") + cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty") cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')") cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)") cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file") diff --git a/internal/app/force_refresh.go b/internal/app/force_refresh.go new file mode 100644 index 00000000..a7d9010a --- /dev/null +++ b/internal/app/force_refresh.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "fmt" + "io" + "log/slog" + "strings" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +// ForceRefreshAccessToken forces a single refresh_token exchange and returns +// the new access_token. It is intended for callers that have observed a +// server-side rejection (HTTP 401 or business code such as +// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token. +// +// Steps: +// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so +// OAuthProvider.GetAccessToken's fast-path will miss. +// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the +// existing dual-layer lock (process + file) to serialize concurrent +// refresh attempts across goroutines and processes. +// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the +// next resolveAuthToken call re-reads from disk. +// +// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper +// is the only entry point that orchestrates "force refresh" semantics. +func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) { + if strings.TrimSpace(configDir) == "" { + return "", fmt.Errorf("config directory is empty") + } + if err := authpkg.MarkAccessTokenStale(configDir); err != nil { + return "", fmt.Errorf("mark access token stale: %w", err) + } + disc := slog.New(slog.NewTextHandler(io.Discard, nil)) + provider := authpkg.NewOAuthProvider(configDir, disc) + configureOAuthProviderCompatibility(provider, configDir) + tok, err := provider.GetAccessToken(ctx) + if err != nil { + return "", err + } + tok = strings.TrimSpace(tok) + if tok == "" { + return "", fmt.Errorf("force refresh returned empty access token") + } + ResetRuntimeTokenCache() + return tok, nil +} diff --git a/internal/app/legacy.go b/internal/app/legacy.go index e9b390e1..ab431612 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -47,12 +47,42 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob return mergeTopLevelCommands(commands) } - var commands []*cobra.Command - if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 { - commands = append(commands, dynamicCmds...) + dynamicCmds := loadDynamicCommands(ctx, runner) + helperCmds := helpers.NewPublicCommands(runner) + return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds)) +} + +// pickCommands returns the union of dynamic and helpers commands, with +// same-named helpers dropped so discovery envelopes remain the authority. +// +// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree +// on same-named top-level commands, which — at leaf conflicts — falls back to +// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands +// typically expose more flags than the corresponding dynamic overlay leaves, +// so a naive append would silently promote helper leaves over their dynamic +// counterparts and append helper-only siblings into the dynamic subtree. +// By shadowing same-named helpers upfront we keep the dynamic subtree intact +// and let helpers only fill in products the discovery envelope did not cover. +func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command { + dynNames := make(map[string]bool, len(dynamic)) + out := make([]*cobra.Command, 0, len(dynamic)+len(helpers)) + for _, c := range dynamic { + if c == nil { + continue + } + dynNames[c.Name()] = true + out = append(out, c) } - commands = append(commands, helpers.NewPublicCommands(runner)...) - return mergeTopLevelCommands(commands) + for _, h := range helpers { + if h == nil { + continue + } + if dynNames[h.Name()] { + continue + } + out = append(out, h) + } + return out } // injectStaticServers converts edition.ServerInfo entries into @@ -87,9 +117,58 @@ func injectStaticServers(servers []edition.ServerInfo) { // // Tests may override discoveryBaseURLOverride to redirect to a local server; // in that case the registry cache is always bypassed. +// editionPartition returns the cache partition for the active edition. +// Each edition gets its own partition to prevent cross-edition data leakage. +func editionPartition() string { + name := edition.Get().Name + if name == "" || name == "open" { + return config.DefaultPartition + } + return name + "/default" +} + +// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics. +// loadDynamicCommands runs while building the command tree, before PersistentPreRun +// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency. +func discoveryTraceEnabled() bool { + if IsPerfDebugEnabled() { + return true + } + for _, a := range os.Args[1:] { + if a == "--debug" { + return true + } + } + return false +} + +func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string { + seen := make(map[string]struct{}) + for _, s := range servers { + id := strings.TrimSpace(s.CLI.Command) + if id == "" { + id = strings.TrimSpace(s.CLI.ID) + } + if id == "" { + continue + } + seen[id] = struct{}{} + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + const maxIDs = 48 + if len(out) > maxIDs { + out = out[:maxIDs] + } + return out +} + func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command { store := cacheStoreFromEnv() - partition := config.DefaultPartition + partition := editionPartition() // Bypass the registry cache when a fixture override is active. // This ensures tests that set DWS_CATALOG_FIXTURE always get fresh @@ -116,27 +195,55 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C } } + if len(servers) > 0 && discoveryTraceEnabled() { + slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache", + "partition", partition, + "servers", len(servers), + "registry_freshness", string(freshness)) + } + // Cache miss or bypassed: fetch from market API synchronously (first run only). if len(servers) == 0 { - baseURL := cli.DefaultMarketBaseURL - if discoveryBaseURLOverride != "" { - baseURL = discoveryBaseURLOverride + if discoveryTraceEnabled() { + if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" { + slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL) + } else { + baseURL := cli.DefaultMarketBaseURL + if discoveryBaseURLOverride != "" { + baseURL = discoveryBaseURLOverride + } + slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL) + } } fetchStart := time.Now() - client := market.NewClient(baseURL, ipv4OnlyHTTPClient()) - resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit) + + resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient()) + RecordTiming(ctx, "market_fetch", time.Since(fetchStart)) if fetchErr != nil { + if discoveryTraceEnabled() { + slog.Info("loadDynamicCommands: sync discovery fetch failed", + "partition", partition, + "error", fetchErr.Error()) + } slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr) // Degrade to stale cache if available (production only). if useCache && cacheErr == nil && len(snapshot.Servers) > 0 { slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers)) servers = snapshot.Servers } else { - return nil + // no-op: fall through to FallbackServers check below } } else { servers = market.NormalizeServers(resp, "market") + if discoveryTraceEnabled() { + slog.Info("loadDynamicCommands: sync discovery fetch ok", + "partition", partition, + "response_servers", len(resp.Servers), + "metadata_count", resp.Metadata.Count, + "normalized_servers", len(servers), + "cli_command_ids", discoveryTraceServerIDs(servers)) + } // Persist fresh data (only in non-test mode). if useCache { saveStart := time.Now() @@ -148,9 +255,22 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C } } + // FallbackServers: safety net when Market discovery + cache both fail. if len(servers) == 0 { + if fn := edition.Get().FallbackServers; fn != nil { + if fb := fn(); len(fb) > 0 { + slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb)) + descriptors := fallbackToDescriptors(fb) + descriptors = mergeSupplementServers(descriptors) + SetDynamicServers(descriptors) + return nil + } + } return nil } + + // Merge edition-specific supplement servers (not in Market). + servers = mergeSupplementServers(servers) // Inject dynamic server data for endpoint resolution SetDynamicServers(servers) @@ -172,7 +292,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor if store == nil { return result } - partition := config.DefaultPartition + partition := editionPartition() for _, server := range servers { if server.DetailLocator.MCPID <= 0 { continue @@ -203,7 +323,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers if ctx == nil { ctx = context.Background() } - partition := config.DefaultPartition + partition := editionPartition() now := time.Now().UTC() if store != nil && store.Now != nil { now = store.Now().UTC() @@ -333,13 +453,13 @@ func DiscoveryBaseURL() string { return cli.DefaultMarketBaseURL } -// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections -// and uses a short timeout suitable for CLI startup network requests. -// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking. -func ipv4OnlyHTTPClient() *http.Client { +// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with +// the given total request timeout. This avoids IPv6 DNS/connect timeouts on +// hosts without IPv6 networking. +func ipv4HTTPClient(timeout time.Duration) *http.Client { dialer := &net.Dialer{Timeout: 3 * time.Second} return &http.Client{ - Timeout: 5 * time.Second, + Timeout: timeout, Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp4", addr) @@ -348,6 +468,29 @@ func ipv4OnlyHTTPClient() *http.Client { } } +// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout +// suitable for CLI startup network requests. +func ipv4OnlyHTTPClient() *http.Client { + return ipv4HTTPClient(5 * time.Second) +} + +// fetchRegistryServers performs the server-list HTTP fetch honoring the +// active edition's DiscoveryURL override. It is the single source of truth +// for all server-list fetches (startup, async revalidation, explicit +// `cache refresh`); keeping the edition-URL branch in one place prevents +// call sites from drifting out of sync. +func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) { + if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" { + client := market.NewClient("", httpClient) + if fn := edition.Get().DiscoveryHeaders; fn != nil { + client.Headers = fn() + } + return client.FetchServersFromURL(ctx, editionURL) + } + client := market.NewClient(DiscoveryBaseURL(), httpClient) + return client.FetchServers(ctx, config.DefaultFetchServersLimit) +} + // asyncRevalidateRegistry refreshes the registry cache in the background. // Uses a short timeout derived from the parent context and silently ignores // errors — the next CLI invocation will pick up the refreshed cache or retry. @@ -355,9 +498,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti ctx, cancel := context.WithTimeout(parent, 30*time.Second) defer cancel() - baseURL := DiscoveryBaseURL() - client := market.NewClient(baseURL, ipv4OnlyHTTPClient()) - resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit) + resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient()) if err != nil { slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err) return @@ -398,3 +539,50 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command { }) return out } + +// mergeSupplementServers appends edition-specific servers (not in Market) +// into the discovery result. Existing IDs from Market/cache take precedence. +func mergeSupplementServers(servers []market.ServerDescriptor) []market.ServerDescriptor { + fn := edition.Get().SupplementServers + if fn == nil { + return servers + } + existing := make(map[string]bool, len(servers)) + for _, s := range servers { + existing[s.CLI.ID] = true + existing[s.Key] = true + } + for _, sup := range fn() { + if !existing[sup.ID] { + servers = append(servers, market.ServerDescriptor{ + Key: sup.ID, + DisplayName: sup.Name, + Endpoint: sup.Endpoint, + CLI: market.CLIOverlay{ + ID: sup.ID, + Command: sup.ID, + Prefixes: sup.Prefixes, + }, + }) + } + } + return servers +} + +// fallbackToDescriptors converts edition.ServerInfo into market.ServerDescriptor. +func fallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor { + descriptors := make([]market.ServerDescriptor, 0, len(servers)) + for _, s := range servers { + descriptors = append(descriptors, market.ServerDescriptor{ + Key: s.ID, + DisplayName: s.Name, + Endpoint: s.Endpoint, + CLI: market.CLIOverlay{ + ID: s.ID, + Command: s.ID, + Prefixes: s.Prefixes, + }, + }) + } + return descriptors +} diff --git a/internal/app/legacy_test.go b/internal/app/legacy_test.go new file mode 100644 index 00000000..83bdc259 --- /dev/null +++ b/internal/app/legacy_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestPickCommands_DynamicShadowsSameNamedHelpers verifies that when the +// discovery envelope produces a dynamic command with the same top-level name +// as a helpers-registered hardcoded command, the helper is dropped and the +// dynamic command is kept verbatim. This prevents mergeTopLevelCommands from +// mixing helper leaves into the dynamic subtree via MergeCommandTree's +// LocalFlagCount-based arbitration. +func TestPickCommands_DynamicShadowsSameNamedHelpers(t *testing.T) { + dyn := &cobra.Command{Use: "todo", Short: "dynamic"} + dyn.AddCommand(&cobra.Command{Use: "task"}) + dynamic := []*cobra.Command{dyn} + + hlp := &cobra.Command{Use: "todo", Short: "helper"} + hlp.AddCommand(&cobra.Command{Use: "task"}) + helpers := []*cobra.Command{hlp} + + got := pickCommands(dynamic, helpers) + + if len(got) != 1 { + t.Fatalf("pickCommands returned %d commands, want 1", len(got)) + } + if got[0] != dyn { + t.Fatalf("pickCommands returned helpers command, want dynamic") + } +} + +// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose +// names are NOT in the dynamic set are preserved — the dynamic overlay only +// shadows products it actually covers. +func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) { + dyn := &cobra.Command{Use: "todo"} + dynamic := []*cobra.Command{dyn} + + todoHelper := &cobra.Command{Use: "todo"} + attendanceHelper := &cobra.Command{Use: "attendance"} + chatHelper := &cobra.Command{Use: "chat"} + helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper} + + got := pickCommands(dynamic, helpers) + + names := make(map[string]*cobra.Command, len(got)) + for _, c := range got { + names[c.Name()] = c + } + if names["todo"] != dyn { + t.Fatalf("todo = %v, want dynamic", names["todo"]) + } + if names["attendance"] != attendanceHelper { + t.Fatalf("attendance not preserved from helpers") + } + if names["chat"] != chatHelper { + t.Fatalf("chat not preserved from helpers") + } + if len(got) != 3 { + t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got)) + } +} + +// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case: +// when discovery returns nothing, helpers are the sole source of truth — the +// behaviour must be identical to the pre-refactor append-all code path. +func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) { + todoHelper := &cobra.Command{Use: "todo"} + chatHelper := &cobra.Command{Use: "chat"} + helpers := []*cobra.Command{todoHelper, chatHelper} + + got := pickCommands(nil, helpers) + + if len(got) != 2 { + t.Fatalf("got %d commands, want 2", len(got)) + } + if got[0] != todoHelper || got[1] != chatHelper { + t.Fatalf("pickCommands changed helpers order or identity") + } +} + +// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from +// a misbehaving factory. +func TestPickCommands_NilsAreSkipped(t *testing.T) { + dyn := &cobra.Command{Use: "todo"} + hlp := &cobra.Command{Use: "chat"} + + got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp}) + + if len(got) != 2 { + t.Fatalf("got %d commands, want 2 (nils filtered)", len(got)) + } + if got[0] != dyn || got[1] != hlp { + t.Fatalf("unexpected ordering or identity after nil filter") + } +} diff --git a/internal/app/root.go b/internal/app/root.go index d7cf3b8a..d61a06e2 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -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 { diff --git a/internal/app/root_cache_refresh_test.go b/internal/app/root_cache_refresh_test.go index eed0e548..ed4d2026 100644 --- a/internal/app/root_cache_refresh_test.go +++ b/internal/app/root_cache_refresh_test.go @@ -12,6 +12,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) { @@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) } } +// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh` +// command routes its server-list fetch through edition.Hooks.DiscoveryURL / +// DiscoveryHeaders when they are set, instead of the default Market endpoint. +// Kept deliberately generic (no edition-specific strings) — concrete values +// belong to the overlay repo that installs the hooks, not to this open core. +func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) { + cacheDir := t.TempDir() + t.Setenv(cli.CacheDirEnv, cacheDir) + + var ( + editionHits atomic.Int32 + marketHits atomic.Int32 + gotHeaders atomic.Value // map[string]string + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/cli/edition/apis": + editionHits.Add(1) + snapshot := map[string]string{ + "x-test-edition": r.Header.Get("x-test-edition"), + "x-test-client": r.Header.Get("x-test-client"), + } + gotHeaders.Store(snapshot) + _ = json.NewEncoder(w).Encode(market.ListResponse{ + Metadata: market.ListMetadata{Count: 1}, + Servers: []market.ServerEnvelope{ + { + Server: market.RegistryServer{ + Name: "Edition Service", + Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}}, + }, + Meta: market.EnvelopeMeta{ + Registry: market.RegistryMetadata{Status: "active"}, + CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"}, + }, + }, + }, + }) + case "/cli/discovery/apis": + marketHits.Add(1) + http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + edition.Override(&edition.Hooks{ + Name: "testing", + DiscoveryURL: srv.URL + "/cli/edition/apis", + DiscoveryHeaders: func() map[string]string { + return map[string]string{ + "x-test-edition": "custom", + "x-test-client": "cli-refresh", + } + }, + }) + t.Cleanup(func() { edition.Override(&edition.Hooks{}) }) + + SetDiscoveryBaseURL(srv.URL) + t.Cleanup(func() { SetDiscoveryBaseURL("") }) + + cmd := newCacheCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"refresh"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if got := editionHits.Load(); got != 1 { + t.Fatalf("edition DiscoveryURL hits = %d, want 1", got) + } + if got := marketHits.Load(); got != 0 { + t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got) + } + + headers, _ := gotHeaders.Load().(map[string]string) + if headers == nil { + t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied") + } + if headers["x-test-edition"] != "custom" { + t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom") + } + if headers["x-test-client"] != "cli-refresh" { + t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh") + } +} + func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) { t.Helper() diff --git a/internal/app/runner.go b/internal/app/runner.go index e17227d1..89cd852a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -168,10 +168,10 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) product, ok := catalog.FindProduct(invocation.CanonicalProduct) if !ok || strings.TrimSpace(product.Endpoint) == "" { - return r.fallback.Run(ctx, invocation) + return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override") } if _, ok := product.FindTool(invocation.Tool); !ok { - return r.fallback.Run(ctx, invocation) + return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct)) } if r.globalFlags != nil && r.globalFlags.DryRun { invocation.DryRun = true @@ -184,6 +184,37 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) return r.executeInvocation(ctx, endpoint, invocation) } +// handleCatalogMiss decides what to do when discovery catalog does not cover the +// requested product / tool and no `directRuntimeEndpoint` match fired earlier. +// +// Previously every catalog miss silently fell through to EchoRunner, which +// returns an empty `executor.Result{Response: nil}`. The helper-invocation +// adapter then converted that into `&edition.ToolResult{}`, whose `Content` +// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no +// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace. +// +// New contract: +// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so +// `--dry-run` still prints the planned payload without real execution. +// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved") +// with the offending product/tool attached. This fails fast to stderr and +// makes missing envelopes / supplement gaps immediately visible. +func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) { + dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun) + if dryRun { + invocation.DryRun = true + return r.fallback.Run(ctx, invocation) + } + return executor.Result{}, apperrors.NewAPI( + fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail), + apperrors.WithOperation("discovery.resolve"), + apperrors.WithReason("endpoint_not_resolved"), + apperrors.WithServerKey(invocation.CanonicalProduct), + apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"), + apperrors.WithActions("dws cache refresh"), + ) +} + func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) { // Route stdio:// endpoints to the local StdioClient — no HTTP, no auth. if IsStdioEndpoint(endpoint) { diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index f5267063..3f0b4cc0 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -26,6 +26,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp" ) @@ -33,6 +34,13 @@ import ( func setupRuntimeCommandTest(t *testing.T) { t.Helper() t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + // Isolate keychain storage so concurrent test packages can't leak a + // real auth token into runtime tests via the shared on-disk keychain + // location. We deliberately do NOT reset the process-wide token + // cache here: getCachedRuntimeToken uses sync.Once and is read by + // detached preload goroutines spawned per Run(); replacing the Once + // races with those still-running goroutines. + t.Setenv(keychain.StorageDirEnv, t.TempDir()) discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(contactDiscoveryResponse()) @@ -336,7 +344,17 @@ func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) { } } -func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) { +// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix +// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog +// does not carry the requested product (here `contact` against a +// doc-only fixture), `runtimeRunner.Run` must surface an explicit +// `endpoint_not_resolved` error instead of silently echoing an empty result +// back to the user — the latter is what historically produced `{"Content": +// null}` output on `dws doc list`. +// +// Dry-run / compat semantics continue to route through EchoRunner and are +// covered by other tests in this file (TestRuntime*Dry*, TestCompat*). +func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) { setupRuntimeCommandTest(t) server := mockmcp.DefaultServer() defer server.Close() @@ -349,33 +367,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) { cmd.SetErr(&out) cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute() error = %v", err) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String()) } - - var payload struct { - Invocation struct { - Implemented bool `json:"implemented"` - CanonicalProduct string `json:"canonical_product"` - Tool string `json:"tool"` - } `json:"invocation"` - Response map[string]any `json:"response"` + msg := err.Error() + if !strings.Contains(msg, "endpoint not resolved") { + t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg) } - if err := json.Unmarshal(out.Bytes(), &payload); err != nil { - t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String()) - } - - if payload.Invocation.Implemented { - t.Fatalf("implemented = true, want false for fallback") - } - if payload.Invocation.CanonicalProduct != "contact" { - t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct) - } - if payload.Invocation.Tool != "get_current_user_profile" { - t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool) - } - if payload.Response != nil { - t.Fatalf("response = %#v, want nil for echo fallback", payload.Response) + if !strings.Contains(msg, "contact") { + t.Fatalf("error message = %q, want it to mention product \"contact\"", msg) } } diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go new file mode 100644 index 00000000..75333de3 --- /dev/null +++ b/internal/app/testmain_test.go @@ -0,0 +1,50 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "os" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +// TestMain isolates the on-disk keychain storage to a process-wide +// temporary directory for the entire internal/app test binary. +// +// Background: getCachedRuntimeToken caches the auth token via sync.Once +// for the process lifetime. Whichever test triggers it first locks in the +// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget) +// call SaveTokenData and then exec a CLI command that triggers Once.Do; if +// keychain storage points at the developer's real ~/Library/Application +// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be +// written there and cached process-wide, breaking later tests that assume +// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest. +// +// Setting keychain.StorageDirEnv here forces every keychain read/write in +// this binary into a per-process tempdir, eliminating that contamination +// without touching production code. +func TestMain(m *testing.M) { + tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-") + if err != nil { + panic("create test keychain tempdir: " + err.Error()) + } + if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.StorageDirEnv + ": " + err.Error()) + } + code := m.Run() + _ = os.RemoveAll(tmpDir) + os.Exit(code) +} diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 95daf49f..3617e84f 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -16,6 +16,7 @@ package app import ( "context" "encoding/json" + "log/slog" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -56,6 +57,19 @@ func (a *toolCallerAdapter) DryRun() bool { func convertResult(r executor.Result) *edition.ToolResult { resp := r.Response if resp == nil { + // After the fix-wukong-discovery-missing-servers Phase 3 change, + // runtimeRunner.Run returns an explicit error for catalog misses + // instead of an empty Response, so this branch should only be + // reachable for unit tests / unexpected runners. Log a warning so + // any future regression (silent `{"Content": null}` on the CLI) + // leaves a trace in the file logger / stderr. + slog.Warn( + "tool_caller_adapter: empty runner response — upstream should surface an error instead", + "product", r.Invocation.CanonicalProduct, + "tool", r.Invocation.Tool, + "kind", r.Invocation.Kind, + "dry_run", r.Invocation.DryRun, + ) return &edition.ToolResult{} } diff --git a/internal/auth/credential_reset_test.go b/internal/auth/credential_reset_test.go new file mode 100644 index 00000000..9542a775 --- /dev/null +++ b/internal/auth/credential_reset_test.go @@ -0,0 +1,319 @@ +package auth + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// resetAppConfigCache clears cached app config so tests get a fresh load. +func resetAppConfigCache() { + cachedAppConfigMu.Lock() + cachedAppConfig = nil + cachedAppConfigMu.Unlock() + cachedAppConfigOnce = sync.Once{} + + cachedResolvedMu.Lock() + cachedResolvedValid = false + cachedResolvedID = "" + cachedResolvedSecret = "" + cachedResolvedMu.Unlock() +} + +// ─── Issue #155: Defensive credential reset ──────────────────────────── +// +// These tests verify that both DeviceFlowProvider and OAuthProvider always +// reset credential state and re-fetch clientID from MCP, regardless of what +// previous login methods left in app.json or runtime state. + +func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) { + // Scenario: OAuth login saved app.json WITHOUT Source field (the original bug). + // Device flow should ignore the stale clientID, reset state, and re-fetch from MCP. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + // Simulate OAuth login: saved app.json with clientId but NO Source field + oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}` + if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + // Verify precondition: ClientID() loads stale value from app.json + resetAppConfigCache() + gotID := ClientID() + if gotID != "ding-oauth-stale" { + t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID) + } + if IsClientIDFromMCP() { + t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source") + } + + // Now create a DeviceFlowProvider — it should pick up the stale clientID + provider := NewDeviceFlowProvider(dir, nil) + if provider.clientID != "ding-oauth-stale" { + t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID) + } + + // Key assertion: after prepareCredentials(), the provider should have + // cleared the stale clientID and be ready for MCP fetch. + // We can't call Login() directly (needs real MCP server), but we can + // verify the reset logic by calling the new method directly. + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false") + } +} + +func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) { + // Scenario: Legacy app.json (no Source field at all) exists from an old CLI version. + // Device flow should reset and re-fetch. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + // Write legacy app.json + legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}` + if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + resetAppConfigCache() + provider := NewDeviceFlowProvider(dir, nil) + + // Verify stale clientID was loaded + if provider.clientID != "ding-legacy-old" { + t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID) + } + + // Reset should clear it + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } +} + +func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) { + // Scenario: User previously logged in with --client-id + --client-secret (direct mode). + // app.json has a different clientId. Device flow should reset and re-fetch from MCP. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + // Simulate direct-mode app.json with clientSecret stored + if err := SaveAppConfig(dir, &AppConfig{ + ClientID: "ding-direct-custom", + ClientSecret: PlainSecret("some-secret"), + }); err != nil { + t.Fatalf("SaveAppConfig error: %v", err) + } + + resetAppConfigCache() + provider := NewDeviceFlowProvider(dir, nil) + + // Verify the direct clientID was loaded + if provider.clientID != "ding-direct-custom" { + t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID) + } + + // Reset should clear it + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false") + } +} + +func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) { + // Scenario: MCP flag is already set from a previous device login in same process. + // Device flow should still reset and re-fetch to ensure freshness. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + // Simulate: MCP flag is already set from previous login + SetClientIDFromMCP("ding-old-mcp") + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + provider := NewDeviceFlowProvider(dir, nil) + if provider.clientID != "ding-old-mcp" { + t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID) + } + + // Reset should clear both clientID and MCP flag + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset") + } +} + +func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) { + // Scenario: No app.json exists (first time login). Device flow should work normally. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + provider := NewDeviceFlowProvider(dir, nil) + + // clientID should already be empty + if provider.clientID != "" { + t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID) + } + + // Reset should be a no-op but not crash + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false") + } +} + +// ─── OAuthProvider defensive reset (--force login) ───────────────────── + +func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) { + // Scenario: Previous login saved app.json with MCP-fetched clientID but + // no Source marker. OAuth --force login should reset and re-fetch. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + // Simulate previous login: app.json with clientId but no Source + staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}` + if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + resetAppConfigCache() + provider := NewOAuthProvider(dir, nil) + + // Verify stale clientID was loaded + if provider.clientID != "ding-stale-oauth" { + t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID) + } + + // Reset should clear it + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false") + } +} + +func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) { + // Scenario: MCP flag is already set. OAuth --force login should still reset. + + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + SetClientIDFromMCP("ding-old-mcp-oauth") + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + resetAppConfigCache() + }) + + provider := NewOAuthProvider(dir, nil) + + provider.resetCredentialState() + + if provider.clientID != "" { + t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID) + } + if IsClientIDFromMCP() { + t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset") + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 6c8f9147..b15394ba 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -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 diff --git a/internal/auth/force_refresh.go b/internal/auth/force_refresh.go new file mode 100644 index 00000000..05504b43 --- /dev/null +++ b/internal/auth/force_refresh.go @@ -0,0 +1,41 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import "time" + +// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past +// instant (preserving access_token and refresh_token), and writes it back. The +// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false +// and proceed to lockedRefresh, exchanging the refresh_token for a fresh +// access_token. +// +// Use this only when the server has rejected the current access_token but the +// local expiry has not yet elapsed (zombie token scenario). It does not delete +// any token material and is safe to call concurrently — actual refresh is +// serialized by lockedRefresh's dual-layer locking. +// +// Returns the original load error when there is no usable token on disk; a +// nil error when there is no access_token to invalidate (no-op). +func MarkAccessTokenStale(configDir string) error { + data, err := LoadTokenData(configDir) + if err != nil { + return err + } + if data == nil || data.AccessToken == "" { + return nil + } + data.ExpiresAt = time.Now().Add(-1 * time.Minute) + return SaveTokenData(configDir, data) +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 8faa65a7..8a6368a6 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -55,6 +55,16 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider { } } +// resetCredentialState clears any stale credential state inherited from +// previous login methods so that OAuth flow always starts fresh by +// fetching clientID from MCP. +func (p *OAuthProvider) resetCredentialState() { + p.clientID = "" + clientMu.Lock() + clientIDFromMCP = false + clientMu.Unlock() +} + func (p *OAuthProvider) output() io.Writer { if p != nil && p.Output != nil { return p.Output @@ -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. diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go new file mode 100644 index 00000000..92310f85 --- /dev/null +++ b/internal/auth/testmain_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "os" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +// TestMain isolates the on-disk keychain storage to a process-wide +// temporary directory for the entire internal/auth test binary so that +// SaveTokenData/DeleteTokenData calls in these tests can never write to +// the developer's real keychain location, preventing cross-package leaks +// when go test runs packages in parallel. +func TestMain(m *testing.M) { + tmpDir, err := os.MkdirTemp("", "dws-auth-test-keychain-") + if err != nil { + panic("create test keychain tempdir: " + err.Error()) + } + if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.StorageDirEnv + ": " + err.Error()) + } + code := m.Run() + _ = os.RemoveAll(tmpDir) + os.Exit(code) +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go index 5dcc87f5..33f77caa 100644 --- a/internal/auth/token_test.go +++ b/internal/auth/token_test.go @@ -20,9 +20,12 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) -// cleanupKeychain removes test data from keychain after test completes. +// cleanupKeychain isolates keychain state to a per-test temporary directory +// so that concurrent test packages (notably internal/app) don't read tokens +// written by these tests, and removes test data on completion. func cleanupKeychain(t *testing.T) { t.Helper() + t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Cleanup(func() { _ = keychain.Remove(keychain.Service, keychain.AccountToken) }) diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 4f7bdaeb..86b23282 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -104,22 +104,38 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru } func NewSchemaCommand(loader CatalogLoader) *cobra.Command { - return &cobra.Command{ - Use: "schema [product.tool]", + cmd := &cobra.Command{ + Use: "schema [path]", Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)", Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。 -不带参数时列出所有产品及其工具数量;带 product.tool 路径时 -输出该工具的完整输入 Schema(JSON Schema 格式)。 +不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整 +输入 Schema(JSON Schema 格式)、输出 Schema、MCP 注解和 CLI +层的 flag overlay(alias/transform/env_default)。 + +路径支持三种写法: + product.rpc_name 规范路径 (e.g. ding.send_ding_message) + product.group.cli_name CLI 点路径 (e.g. ding.message.send) + "product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send") 示例: - dws schema # 列出所有产品 - dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema - dws schema --fields id,tools # 只显示 id 和 tools 字段 - dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`, + dws schema # 列出所有产品 + dws schema ding.send_ding_message # 规范路径 + dws schema "ding message send" # CLI 路径(空格) + dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好) + dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示 + dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + cliPath, _ := cmd.Flags().GetString("cli-path") + cliPath = strings.TrimSpace(cliPath) + if cliPath != "" { + if len(args) > 0 { + return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive") + } + args = []string{cliPath} + } catalog, err := loader.Load(cmd.Context()) if err != nil { var degraded *CatalogDegraded @@ -158,6 +174,8 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { ) }, } + cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)") + return cmd } func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec { @@ -664,7 +682,7 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) { }, nil } - product, tool, ok := catalog.FindTool(args[0]) + product, tool, ok := resolveSchemaPath(catalog, args[0]) if !ok { return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0])) } @@ -676,23 +694,100 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) { }, nil } -// compactTool returns a lean representation of a tool for schema -// output, keeping only the fields that AI agents and developers -// need: name, description, parameters, and sensitivity flag. -func compactTool(t ir.ToolDescriptor) map[string]any { - tool := map[string]any{ - "name": t.RPCName, - "title": t.Title, - "description": t.Description, - "sensitive": t.Sensitive, +// resolveSchemaPath accepts three input forms and maps to (product, tool): +// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message") +// - "product.cli_name" (single-level CLI path, e.g. "doc.create") +// - CLI path with group ("ding message send" or "ding.message.send"; +// also accepts "/" and multiple whitespace between tokens) +// +// Canonical form is tried first so existing callers and scripts keep +// working; only when that fails does the CLI-path resolver run. +func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false } + if product, tool, ok := catalog.FindTool(raw); ok { + return product, tool, true + } + + tokens := splitSchemaPathTokens(raw) + if len(tokens) < 2 { + return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false + } + + productID := tokens[0] + leaf := tokens[len(tokens)-1] + groupPath := strings.Join(tokens[1:len(tokens)-1], ".") + + product, ok := catalog.FindProduct(productID) + if !ok { + return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false + } + + for _, tool := range product.Tools { + if tool.CLIName != leaf { + continue + } + if strings.TrimSpace(tool.Group) != groupPath { + continue + } + return product, tool, true + } + return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false +} + +// splitSchemaPathTokens splits a CLI path on dots, slashes, and +// whitespace, returning only non-empty tokens. "ding message send", +// "ding.message.send", and "ding/message/send" all yield the same +// three tokens. +func splitSchemaPathTokens(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == '.' || r == '/' || r == ' ' || r == '\t' + }) + out := fields[:0] + for _, f := range fields { + if s := strings.TrimSpace(f); s != "" { + out = append(out, s) + } + } + return out +} + +// compactTool returns a lean representation of a tool for schema +// output, keeping the fields AI agents and scripts need: RPC + CLI +// identity, input/output schema, sensitivity, MCP annotations, and the +// CLI flag overlay (alias/transform/envDefault/default) that shapes +// how raw MCP parameters appear on the command line. +func compactTool(t ir.ToolDescriptor) map[string]any { + tool := map[string]any{ + "name": t.RPCName, + "cli_name": t.CLIName, + "canonical_path": t.CanonicalPath, + "title": t.Title, + "description": t.Description, + "sensitive": t.Sensitive, + } + + if strings.TrimSpace(t.Group) != "" { + tool["group"] = t.Group + } if props, ok := t.InputSchema["properties"]; ok { tool["parameters"] = props } if req := requiredFields(t.InputSchema); len(req) > 0 { tool["required"] = req } + if len(t.OutputSchema) > 0 { + tool["output_schema"] = t.OutputSchema + } + if t.Annotations != nil { + tool["annotations"] = t.Annotations + } + if len(t.FlagOverlay) > 0 { + tool["flag_overlay"] = t.FlagOverlay + } return tool } diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index 24ce2aec..84aa72df 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -121,6 +121,205 @@ func TestSchemaPayloadFindsTool(t *testing.T) { } } +func TestCompactToolEmitsExtendedFields(t *testing.T) { + t.Parallel() + + destructive := true + tool := ir.ToolDescriptor{ + RPCName: "send_ding_message", + CLIName: "send", + Group: "message", + CanonicalPath: "ding.send_ding_message", + Title: "发送DING消息", + Description: "desc", + Sensitive: true, + InputSchema: map[string]any{ + "type": "object", + "required": []any{"robotCode"}, + "properties": map[string]any{ + "robotCode": map[string]any{"type": "string"}, + }, + }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "openDingId": map[string]any{"type": "string"}, + }, + }, + Annotations: &ir.ToolAnnotations{DestructiveHint: &destructive}, + FlagOverlay: map[string]ir.FlagOverlay{ + "receiverUserIdList": {Alias: "users", Transform: "csv_to_array"}, + }, + } + + out := compactTool(tool) + if out["name"] != "send_ding_message" { + t.Errorf("name = %v", out["name"]) + } + if out["cli_name"] != "send" { + t.Errorf("cli_name = %v", out["cli_name"]) + } + if out["canonical_path"] != "ding.send_ding_message" { + t.Errorf("canonical_path = %v", out["canonical_path"]) + } + if out["group"] != "message" { + t.Errorf("group = %v", out["group"]) + } + if _, ok := out["output_schema"]; !ok { + t.Errorf("output_schema missing, keys = %v", keysOf(out)) + } + if _, ok := out["annotations"]; !ok { + t.Errorf("annotations missing, keys = %v", keysOf(out)) + } + overlay, ok := out["flag_overlay"].(map[string]ir.FlagOverlay) + if !ok { + t.Fatalf("flag_overlay type = %T", out["flag_overlay"]) + } + if overlay["receiverUserIdList"].Alias != "users" { + t.Errorf("overlay alias = %q", overlay["receiverUserIdList"].Alias) + } +} + +func TestCompactToolOmitsEmptyExtras(t *testing.T) { + t.Parallel() + + tool := ir.ToolDescriptor{ + RPCName: "list_documents", + CLIName: "list", + CanonicalPath: "doc.list_documents", + InputSchema: map[string]any{"type": "object"}, + } + out := compactTool(tool) + for _, key := range []string{"output_schema", "annotations", "flag_overlay", "group"} { + if _, has := out[key]; has { + t.Errorf("key %q should be omitted when empty, got %#v", key, out[key]) + } + } +} + +func TestSchemaPayloadResolvesCLIPath(t *testing.T) { + t.Parallel() + + catalog := ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "ding", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_ding_message", + CLIName: "send", + Group: "message", + CanonicalPath: "ding.send_ding_message", + InputSchema: map[string]any{"type": "object"}, + }, + { + RPCName: "recall_ding_message", + CLIName: "recall", + Group: "message", + CanonicalPath: "ding.recall_ding_message", + InputSchema: map[string]any{"type": "object"}, + }, + }, + }, + }, + } + + cases := []struct { + name string + input string + wantRPC string + wantErr bool + }{ + {"canonical rpc path", "ding.send_ding_message", "send_ding_message", false}, + {"dotted cli path", "ding.message.send", "send_ding_message", false}, + {"space cli path", "ding message send", "send_ding_message", false}, + {"slash cli path", "ding/message/recall", "recall_ding_message", false}, + {"unknown leaf", "ding message nope", "", true}, + {"unknown group", "ding random send", "", true}, + {"unknown product", "nope send", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, err := schemaPayload(catalog, []string{tc.input}) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr) + } + if tc.wantErr { + return + } + tool, ok := payload["tool"].(map[string]any) + if !ok { + t.Fatalf("payload tool missing, got %#v", payload) + } + if tool["name"] != tc.wantRPC { + t.Errorf("tool name = %v, want %s", tool["name"], tc.wantRPC) + } + }) + } +} + +func TestSchemaCommandCLIPathFlag(t *testing.T) { + t.Parallel() + + loader := StaticLoader{Catalog: ir.Catalog{ + Products: []ir.CanonicalProduct{{ + ID: "ding", + Tools: []ir.ToolDescriptor{{ + RPCName: "send_ding_message", + CLIName: "send", + Group: "message", + CanonicalPath: "ding.send_ding_message", + InputSchema: map[string]any{"type": "object"}, + }}, + }}, + }} + + t.Run("resolves via --cli-path", func(t *testing.T) { + cmd := NewSchemaCommand(loader) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--cli-path", "ding message send"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + tool, ok := payload["tool"].(map[string]any) + if !ok { + t.Fatalf("tool missing: %#v", payload) + } + if tool["name"] != "send_ding_message" { + t.Errorf("tool name = %v", tool["name"]) + } + }) + + t.Run("rejects positional + flag collision", func(t *testing.T) { + cmd := NewSchemaCommand(loader) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--cli-path", "ding message send", "ding.send_ding_message"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("expected mutual-exclusion error, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("err = %v, want mutual-exclusion message", err) + } + }) +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + func TestNewMCPCommandReturnsLoaderErrorForInvocations(t *testing.T) { t.Parallel() diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 28c37942..e3ff5c81 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -250,6 +250,13 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) { return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0) } + // Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps, + // dangling toolOverrides.*.serverOverride) to stderr so configuration drift + // is visible at the first `dws cache refresh` / cold start after Portal + // publishes a broken envelope. Non-fatal: discovery continues with the + // accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3. + logDiscoveryWarnings(response.Metadata.Warnings) + servers := market.NormalizeServers(response, "live_market") _ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}) @@ -354,3 +361,19 @@ func (l EnvironmentLoader) lookup(key string) (string, bool) { } return value, true } + +// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr). +// No-op when the response carries an empty / nil Warnings slice — which is the +// common case for older Portal builds that don't populate the field. +func logDiscoveryWarnings(warnings []market.ListWarning) { + if len(warnings) == 0 { + return + } + for _, w := range warnings { + slog.Warn("discovery: merge warning from Portal", + "product", w.ProductID, + "reason", w.Reason, + "detail", w.Detail, + ) + } +} diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index ba0d57d8..b72b1b7c 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -14,18 +14,35 @@ package compat import ( + "context" "encoding/json" "fmt" + "log/slog" "os" "sort" "strings" + "time" "unicode" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) +// runtimeDefaultWhitelist is the closed set of placeholders v3 supports for +// CLIFlagOverride.RuntimeDefault. Placeholders outside this set emit a +// warning at command-build time and are ignored at invocation time. See +// discovery-schema-v3 §2.3. +var runtimeDefaultWhitelist = map[string]bool{ + "$currentUserId": true, + "$unionId": true, + "$corpId": true, + "$now": true, + "$today": true, +} + // BuildDynamicCommands generates cobra commands from servers.json CLIOverlay metadata. // Each server with non-skip CLIOverlay gets a top-level command with groups and // tool overrides translated into subcommands with proper flag bindings and transforms. @@ -47,10 +64,6 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run if cli.Skip { continue } - if len(cli.ToolOverrides) == 0 { - continue - } - // §1.1: cli.command → top-level command name cmdName := strings.TrimSpace(cli.Command) if cmdName == "" { @@ -60,11 +73,35 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run continue } + // §v3.2.6: CLIOverlay.RedirectTo turns the entire top-level product + // into a stub that prints "Please use: dws ". + if target := strings.TrimSpace(cli.RedirectTo); target != "" { + stub := buildOverlayRedirect(cmdName, cli.Description, target) + if cli.Hidden { + stub.Hidden = true + } + // Mark envelope provenance so overlay registrants can tell this + // redirect stub apart from a helper fallback carrying the same + // name; see cmdutil.SourceAnnotation. + cmdutil.MarkEnvelopeSource(stub) + built = append(built, builtCmd{cmd: stub, parent: strings.TrimSpace(cli.Parent)}) + continue + } + + if len(cli.ToolOverrides) == 0 { + continue + } + rootCmd := NewGroupCommand(cmdName, cli.Description) // §1.5: cli.hidden → entire service hidden if cli.Hidden { rootCmd.Hidden = true } + // Mark envelope provenance so edition overlays can distinguish this + // dynamic root from a same-named helper fallback when deciding + // whether to merge hardcoded leaves or evict and replace. See + // cmdutil.SourceAnnotation and the wukong overlay's RegisterProducts. + cmdutil.MarkEnvelopeSource(rootCmd) // Build detail index for this server: toolName → DetailTool detailIndex := buildDetailIndex(detailsByID[strings.TrimSpace(cli.ID)]) @@ -93,6 +130,13 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run cliName = deriveCommandName(toolName, cli.Prefixes) } + // §P2.redirect: redirectTo turns this entry into a stub. + if target := strings.TrimSpace(override.RedirectTo); target != "" { + redirect := buildRedirectCommand(cliName, override.Description, target) + attachToGroup(rootCmd, override.Group, groupCmds, redirect) + continue + } + bindings, normalizer := buildOverrideBindings(override) // Resolve Short/Long from Detail API toolTitle/toolDesc; @@ -111,12 +155,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run } } + // ServerOverride routes this leaf's tool invocation to a different + // product's MCP server; fall back to the enclosing overlay's ID. + canonicalProduct := strings.TrimSpace(override.ServerOverride) + if canonicalProduct == "" { + canonicalProduct = strings.TrimSpace(cli.ID) + } + route := Route{ Use: cliName, Short: short, Long: long, + // Preserve left-side indentation: cobra's Examples template + // renders {{.Example}} verbatim, and hardcoded helper commands + // rely on a 2-space prefix to look indented under "Examples:". + // Only trim trailing whitespace/newlines so envelope JSON can + // safely carry a closing "\n" without doubling the blank line. + Example: strings.TrimRight(override.Example, " \t\r\n"), Target: Target{ - CanonicalProduct: strings.TrimSpace(cli.ID), + CanonicalProduct: canonicalProduct, Tool: toolName, }, Bindings: bindings, @@ -128,6 +185,9 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run route.Normalizer = chainSensitiveNormalizer(normalizer) } + // §v3.2.5: outputFormat.rename/drop/columns post-processing. + route.OutputTransform = buildOutputTransform(override.OutputFormat) + cmd := NewDirectCommand(route, runner) // Enrich flags with typed parameters from Detail API toolRequest JSON Schema. @@ -135,13 +195,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags) } + // §P2.flagconstraints: must run AFTER schema enrichment because the + // target flags may be registered lazily by buildFlagsFromDetailSchema. + applyFlagConstraints(cmd, override) + // §1.4: Add to the right parent group - groupName := strings.TrimSpace(override.Group) - if groupName != "" { - parent := resolveNestedGroup(rootCmd, groupName, groupCmds) - parent.AddCommand(cmd) - } else { - rootCmd.AddCommand(cmd) + attachToGroup(rootCmd, override.Group, groupCmds, cmd) + } + + // §P2.hints: attach hint stub commands registered on the overlay. + if len(cli.Hints) > 0 { + hintNames := make([]string, 0, len(cli.Hints)) + for name := range cli.Hints { + hintNames = append(hintNames, name) + } + sort.Strings(hintNames) + for _, name := range hintNames { + def := cli.Hints[name] + hint := buildHintCommand(name, def) + attachToGroup(rootCmd, def.Group, groupCmds, hint) } } @@ -166,7 +238,7 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run } for _, child := range children { if parent, ok := topLevel[child.parent]; ok { - parent.AddCommand(child.cmd) + attachOrMerge(parent, child.cmd) } else { // Parent not found among dynamic commands; emit as top-level. name := child.cmd.Name() @@ -238,6 +310,15 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr for _, key := range keys { prop := schema.Properties[key] + // Skip properties that are bound as positional arguments; they are + // collected from cobra args rather than flags. For dual-mode positional + // bindings (envelope: positional + alias/aliases), ApplyBindings has + // already registered the alias flags, so we should not re-register + // them here from the MCP detail schema. + if ov, ok := flagOverrides[key]; ok && ov.Positional { + continue + } + // Determine flag name: prefer alias from CLIFlagOverride, else kebab-case. flagName := toKebabCase(key) if ov, ok := flagOverrides[key]; ok && strings.TrimSpace(ov.Alias) != "" { @@ -389,6 +470,43 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri return ensureNestedGroup(root, groupPath, groupPath, registry) } +// attachOrMerge adds child as a sub-command of parent. If parent already has a +// sub-command with the same Name(), the two are merged recursively: child's +// sub-commands are moved onto the existing one and child itself is discarded. +// Leaf collisions (two commands with the same Name and no further children) +// are resolved first-wins — the incoming one is dropped. +// +// This lets multiple server entries share a cli.command under the same parent, +// e.g. bot-message (command="message", parent="chat") can contribute leaves +// into the same "message" subtree already built from chat's own toolOverrides, +// without creating a duplicate "message" sibling in chat's help output. +func attachOrMerge(parent, child *cobra.Command) { + existing := findSubcommand(parent, child.Name()) + if existing == nil { + parent.AddCommand(child) + return + } + // Snapshot child's sub-commands before we start moving them (RemoveCommand + // mutates the slice we'd be iterating). + subs := make([]*cobra.Command, len(child.Commands())) + copy(subs, child.Commands()) + for _, sub := range subs { + child.RemoveCommand(sub) + attachOrMerge(existing, sub) + } +} + +// findSubcommand returns the first sub-command of parent with the given name, +// or nil if none match. +func findSubcommand(parent *cobra.Command, name string) *cobra.Command { + for _, sub := range parent.Commands() { + if sub.Name() == name { + return sub + } + } + return nil +} + // buildOverrideBindings converts CLIToolOverride flags into FlagBindings and // constructs a Normalizer that applies transform rules. // Implements §2.1-§2.5 of the conversion rules. @@ -415,21 +533,46 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm envVar string } var envDefaults []envDefaultEntry - type hiddenDefaultEntry struct { + // defaultInjectEntry captures envelope flag.default values that must be + // injected into the MCP body when the user omits the flag. v3.2 widened + // this from hidden-only to all flags so that visible flags carrying a + // default (e.g. oa list-forms cursor=0 / pageSize=100) match the + // hardcoded helper command behavior of `mustGetFlag(cobra default) → + // body`. The kind drives typed coercion at injection time so a + // `type: int` envelope default reaches MCP as `int(0)`, not string `"0"`. + type defaultInjectEntry struct { paramName string defaultValue string + kind ValueKind } - var hiddenDefaults []hiddenDefaultEntry + var defaultInjects []defaultInjectEntry + type runtimeDefaultEntry struct { + paramName string + placeholder string + } + var runtimeDefaults []runtimeDefaultEntry + type omitEntry struct { + paramName string + mode string // "empty" (default) | "zero" | "never" + } + omits := make(map[string]omitEntry, len(paramNames)) for _, paramName := range paramNames { flagOverride := override.Flags[paramName] - // §2.2: flag name from alias, fallback to kebab-case of param name - flagName := strings.TrimSpace(flagOverride.Alias) - if flagName == "" { + // §2.2: flag name from alias, fallback to kebab-case of param name. + // For pure positional bindings (no alias declared) we deliberately + // leave FlagName empty so ApplyBindings / NewDirectCommand can + // distinguish "envelope wants flag-or-positional dual entry" from + // "envelope only wants positional". Auto-deriving a flag name here + // would otherwise leak a redundant `--` flag and confuse + // the dual-mode detection. + explicitAlias := strings.TrimSpace(flagOverride.Alias) + flagName := explicitAlias + if flagName == "" && !flagOverride.Positional { flagName = compatFlagName(paramName) } - if flagName == "" { + if flagName == "" && !flagOverride.Positional { flagName = paramName } @@ -438,23 +581,68 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm continue } - binding := FlagBinding{ - FlagName: flagName, - Property: paramName, - Kind: ValueString, - Usage: paramName, + // §2.2 (aliases): deduplicate additional hidden aliases against the + // primary name + reserved names. Preserves envelope declaration order + // so CLI precedence (primary > Alias > Aliases[0..n]) is deterministic. + var extraAliases []string + if len(flagOverride.Aliases) > 0 { + seen := map[string]bool{"json": true, "params": true} + if flagName != "" { + seen[flagName] = true + } + extraAliases = make([]string, 0, len(flagOverride.Aliases)) + for _, a := range flagOverride.Aliases { + a = strings.TrimSpace(a) + if a == "" || seen[a] { + continue + } + seen[a] = true + extraAliases = append(extraAliases, a) + } } - // §2.5: hidden flag with default - if flagOverride.Hidden { - // Hidden flags are still added but marked hidden. - // They are auto-populated with their default value via the normalizer. - if flagOverride.Default != "" { - hiddenDefaults = append(hiddenDefaults, hiddenDefaultEntry{ - paramName: paramName, - defaultValue: flagOverride.Default, - }) - } + // Usage defaults to paramName but an explicit Description on the + // overlay wins (it also beats the Detail API's toolDesc during flag + // enrichment because buildFlagsFromDetailSchema preserves overlay usage). + usage := paramName + if desc := strings.TrimSpace(flagOverride.Description); desc != "" { + usage = desc + } + + binding := FlagBinding{ + FlagName: flagName, + Aliases: extraAliases, + Short: strings.TrimSpace(flagOverride.Shorthand), + Property: paramName, + Kind: kindFromTypeName(flagOverride.Type), + Usage: usage, + // §P1: Required is preserved for positional bindings too. For + // pure positional, cobra arity (MinimumNArgs) enforces presence + // at parse time. For dual-mode positional (positional + alias), + // validateRequiredPositionalBindings closes the loop in RunE + // after both flag and positional injection, so MarkFlagRequired + // is intentionally avoided. + Required: flagOverride.Required, + // §2.4: Default drives both cobra's --help "(default ...)" + // rendering and (since v3.2) MCP body injection when the user + // omits the flag. CollectBindings still gates writes by + // user-changed flags, so user-provided values always win; the + // normalizer's defaultInjects loop only fills missing keys. + Default: flagOverride.Default, + Positional: flagOverride.Positional, + PositionalIndex: flagOverride.PositionalIndex, + } + + // §v3.2: any non-empty default — hidden or visible — gets injected + // when the user omits the flag. Earlier versions gated this on + // flagOverride.Hidden which left visible flags with `"default": "0"` + // (e.g. oa list-forms cursor) silently absent from the MCP body. + if flagOverride.Default != "" { + defaultInjects = append(defaultInjects, defaultInjectEntry{ + paramName: paramName, + defaultValue: flagOverride.Default, + kind: binding.Kind, + }) } bindings = append(bindings, binding) @@ -472,10 +660,23 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm envVar: flagOverride.EnvDefault, }) } + if rd := strings.TrimSpace(flagOverride.RuntimeDefault); rd != "" { + if !runtimeDefaultWhitelist[rd] { + fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: unknown placeholder %q on %s; ignoring\n", rd, paramName) + } else { + runtimeDefaults = append(runtimeDefaults, runtimeDefaultEntry{ + paramName: paramName, + placeholder: rd, + }) + } + } + if mode := strings.TrimSpace(flagOverride.OmitWhen); mode != "" && mode != "empty" { + omits[paramName] = omitEntry{paramName: paramName, mode: mode} + } } // Check if we need a normalizer: transforms, env defaults, hidden defaults, - // or dotted property paths that need nesting. + // runtime defaults, omit-when overrides, dotted property paths, or body wrapper. needsDottedNesting := false for _, b := range bindings { if strings.Contains(b.Property, ".") { @@ -483,16 +684,32 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm break } } - if len(transforms) == 0 && len(envDefaults) == 0 && len(hiddenDefaults) == 0 && !needsDottedNesting { + bodyWrapper := strings.TrimSpace(override.BodyWrapper) + if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" { return bindings, nil } - // Build a normalizer that applies hidden defaults + env defaults + transforms + nesting + // Build a normalizer that applies default injections + env defaults + runtime defaults + // + transforms + omitWhen + nesting + body wrap. normalizer := func(cmd *cobra.Command, params map[string]any) error { - // §2.5: Apply hidden flag defaults for parameters not explicitly set - for _, hd := range hiddenDefaults { - if _, exists := params[hd.paramName]; !exists { - params[hd.paramName] = hd.defaultValue + // §v3.2: Apply envelope flag.default for parameters not explicitly set. + // Coerce by Kind so number-typed schemas don't reject string defaults. + for _, di := range defaultInjects { + if _, exists := params[di.paramName]; exists { + continue + } + defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(di.kind, di.defaultValue) + switch di.kind { + case ValueInt: + params[di.paramName] = defInt + case ValueFloat: + params[di.paramName] = defFloat + case ValueBool: + params[di.paramName] = defBool + case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: + params[di.paramName] = defSlice + default: // ValueString, ValueJSON, and any unknown kind + params[di.paramName] = defStr } } @@ -505,6 +722,26 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm } } + // §v3.2.3: Apply runtime defaults (lowest priority, last default fill). + if len(runtimeDefaults) > 0 { + resolvers := runtimeDefaultResolvers() + for _, rd := range runtimeDefaults { + if _, exists := params[rd.paramName]; exists { + continue + } + resolver, ok := resolvers[rd.placeholder] + if !ok { + // Whitelisted but no provider registered (common on + // open-source core). Emit a single warning and move on. + fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: no resolver registered for %s; skipping %s\n", rd.placeholder, rd.paramName) + continue + } + if val, ok := resolver(cmd.Context()); ok && val != "" { + params[rd.paramName] = val + } + } + } + // §3: Apply transforms for _, t := range transforms { val, exists := params[t.paramName] @@ -524,15 +761,155 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm params[t.paramName] = transformed } + // §v3.2.2: Apply omitWhen — drop keys whose value meets the omit + // condition for the declared mode. Default mode "empty" is already + // handled implicitly by CollectBindings (empty string / empty slice + // never enters params), so we only deal with "zero" and "never". + for _, o := range omits { + applyOmitWhen(params, o.paramName, o.mode) + } + // Nest dotted property paths: "Body.query" → params["Body"]["query"] nestDottedPaths(params) + // §P2.bodyWrapper: wrap user-facing params under a single named key. + // Internal control keys (prefixed with '_' e.g. _blocked, _yes) stay + // at the top level so downstream confirmation logic keeps working. + if bodyWrapper != "" { + wrapParamsIntoBody(params, bodyWrapper) + } + return nil } return bindings, normalizer } +// wrapParamsIntoBody moves every non-internal key from params into a new +// map stored under params[wrapper]. Internal keys (leading underscore) are +// preserved at the top level so the dispatcher / --yes logic still sees +// them. If params already contains params[wrapper] it is merged in first. +func wrapParamsIntoBody(params map[string]any, wrapper string) { + if wrapper == "" { + return + } + body := map[string]any{} + if existing, ok := params[wrapper].(map[string]any); ok { + for k, v := range existing { + body[k] = v + } + delete(params, wrapper) + } + for key, value := range params { + if strings.HasPrefix(key, "_") { + continue + } + body[key] = value + delete(params, key) + } + params[wrapper] = body +} + +// attachToGroup places cmd under the right parent based on the dotted group +// path. Empty group means attach directly to the overlay root. +func attachToGroup(root *cobra.Command, groupPath string, groupCmds map[string]*cobra.Command, cmd *cobra.Command) { + gp := strings.TrimSpace(groupPath) + if gp == "" { + root.AddCommand(cmd) + return + } + parent := resolveNestedGroup(root, gp, groupCmds) + parent.AddCommand(cmd) +} + +// buildRedirectCommand returns a stub leaf that prints "use: " and +// performs no tool invocation. Accepts unknown flags/args so users hitting +// the old path get the redirect message instead of a parse error. +func buildRedirectCommand(name, description, target string) *cobra.Command { + short := strings.TrimSpace(description) + if short == "" { + short = fmt.Sprintf("moved → %s", target) + } + cmd := &cobra.Command{ + Use: name, + Short: short, + Long: fmt.Sprintf("This command has moved. Please use: %s", target), + DisableFlagParsing: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Fprintf(cmd.OutOrStdout(), "This command has moved. Please use: %s\n", target) + return nil + }, + } + return cmd +} + +// buildHintCommand returns a stub sub-command that prints a redirect hint +// to the canonical command path declared by the overlay's hintCommands entry. +func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command { + target := strings.TrimSpace(def.Target) + short := strings.TrimSpace(def.Description) + if short == "" { + if target != "" { + short = fmt.Sprintf("hint: use %s", target) + } else { + short = "hint: see --help for the canonical command" + } + } + cmd := &cobra.Command{ + Use: name, + Short: short, + Long: short, + DisableFlagParsing: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if target != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Please use: %s\n", target) + } else { + _ = cmd.Help() + } + return nil + }, + } + return cmd +} + +// applyFlagConstraints wires mutuallyExclusive / requireOneOf declarations +// onto the cobra command. Unknown flag names are logged and skipped so a +// stale/malformed overlay never blocks the entire command tree from building. +func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) { + validate := func(names []string) []string { + valid := make([]string, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + if cmd.Flags().Lookup(n) == nil { + fmt.Fprintf(os.Stderr, "[discovery] flag constraint references unknown flag --%s on %q; skipping\n", n, cmd.Name()) + return nil + } + valid = append(valid, n) + } + return valid + } + + for _, group := range override.MutuallyExclusive { + names := validate(group) + if len(names) < 2 { + continue + } + cmd.MarkFlagsMutuallyExclusive(names...) + } + for _, group := range override.RequireOneOf { + names := validate(group) + if len(names) < 1 { + continue + } + cmd.MarkFlagsOneRequired(names...) + } +} + // chainSensitiveNormalizer wraps a normalizer with --yes confirmation for sensitive operations (§5.1). func chainSensitiveNormalizer(inner Normalizer) Normalizer { return func(cmd *cobra.Command, params map[string]any) error { @@ -581,6 +958,101 @@ func sortedToolNames(m map[string]market.CLIToolOverride) []string { return keys } +// kindFromTypeName maps the v3 CLIFlagOverride.Type declaration to the +// internal FlagBinding.Kind enum. Empty / unknown → ValueString (which keeps +// the v2 behaviour where every overlay flag was a plain string). +func kindFromTypeName(typeName string) ValueKind { + switch strings.TrimSpace(strings.ToLower(typeName)) { + case "int", "integer", "number": + return ValueInt + case "bool", "boolean": + return ValueBool + case "stringslice", "string_slice", "[]string": + return ValueStringSlice + case "string", "": + return ValueString + default: + fmt.Fprintf(os.Stderr, "[discovery] flag type %q not recognised; defaulting to string\n", typeName) + return ValueString + } +} + +// runtimeDefaultResolvers returns the edition-provided resolver map plus +// built-in fallbacks for $now / $today (which are trivially local). Overlays +// are expected to register the user-identity placeholders; $now / $today are +// always available. +func runtimeDefaultResolvers() map[string]edition.RuntimeDefaultFn { + resolvers := make(map[string]edition.RuntimeDefaultFn, len(runtimeDefaultWhitelist)) + resolvers["$now"] = func(ctx context.Context) (string, bool) { + return fmt.Sprintf("%d", time.Now().UnixMilli()), true + } + resolvers["$today"] = func(ctx context.Context) (string, bool) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.FixedZone("CST", 8*3600) + } + return time.Now().In(loc).Format("2006-01-02"), true + } + if hooks := edition.Get(); hooks != nil && hooks.RuntimeDefaults != nil { + for id, fn := range hooks.RuntimeDefaults() { + if fn != nil { + resolvers[id] = fn + } + } + } + return resolvers +} + +// applyOmitWhen drops a key from params when its value meets the omit +// condition for the declared mode. "empty" (the default) is handled by +// CollectBindings upstream, so this function only processes "zero" and +// "never" — "never" is a marker that keeps the zero value explicit, so we +// do nothing for it. +func applyOmitWhen(params map[string]any, key, mode string) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "never": + return + case "zero": + val, exists := params[key] + if !exists { + return + } + if isZeroValue(val) { + delete(params, key) + } + default: + // "empty" is the default, no-op. + } + // Emit a trace for anyone debugging envelope behaviour; kept at Debug so + // it never leaks into the default CLI output. + slog.Debug("applyOmitWhen", "key", key, "mode", mode) +} + +func isZeroValue(v any) bool { + switch val := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(val) == "" + case bool: + return !val + case int: + return val == 0 + case int64: + return val == 0 + case float64: + return val == 0 + case []any: + return len(val) == 0 + case []string: + return len(val) == 0 + case map[string]any: + return len(val) == 0 + default: + return false + } +} + // nestDottedPaths converts flat dotted keys in params into nested maps. // Example: params["Body.query"] = "test" → params["Body"] = map{"query": "test"} // If multiple dotted keys share a prefix, they are merged into the same nested map. diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go index 68f06cc2..1bd06621 100644 --- a/internal/compat/dynamic_commands_test.go +++ b/internal/compat/dynamic_commands_test.go @@ -14,12 +14,40 @@ package compat import ( + "context" + "strings" "testing" + "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" ) +// captureRunner records the most recent invocation for assertion. +type captureRunner struct { + lastProduct string + lastTool string + lastParams map[string]any +} + +func (c *captureRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + c.lastProduct = inv.CanonicalProduct + c.lastTool = inv.Tool + c.lastParams = inv.Params + return executor.Result{Invocation: inv}, nil +} + +// findChild returns the direct sub-command with the given name, or nil. +func findChild(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + func TestBuildDynamicCommands_ParentNesting(t *testing.T) { t.Parallel() @@ -111,6 +139,1254 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) { } } +// Phase 0 P1 schema extensions ----------------------------------------------- + +// TestBuildDynamicCommands_ShorthandFlag verifies the Shorthand field wires +// through to cobra's StringP short form. +func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "conversationId": { + Alias: "conv", + Shorthand: "c", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + if len(cmds) != 1 { + t.Fatalf("expected 1 cmd, got %d", len(cmds)) + } + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + f := send.Flags().Lookup("conv") + if f == nil { + t.Fatal("--conv flag missing") + } + if f.Shorthand != "c" { + t.Fatalf("expected shorthand 'c', got %q", f.Shorthand) + } +} + +// TestBuildDynamicCommands_RequiredFlag verifies Required marks the flag via +// cobra.MarkFlagRequired (recorded under the BashCompletion annotation). +func TestBuildDynamicCommands_RequiredFlag(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "conversationId": { + Alias: "conv", + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + f := send.Flags().Lookup("conv") + if f == nil { + t.Fatal("--conv flag missing") + } + if _, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; !ok { + t.Fatalf("expected --conv to be marked required, annotations=%v", f.Annotations) + } +} + +// TestBuildDynamicCommands_RequiredIgnoredWhenPositional verifies that +// Required is ignored when Positional is true (cobra arity handles it). +func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "text": { + Positional: true, + PositionalIndex: 0, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + if f := send.Flags().Lookup("text"); f != nil { + t.Fatalf("positional 'text' must not be registered as flag: %+v", f) + } +} + +// TestBuildDynamicCommands_PositionalArg verifies Positional params are NOT +// registered as flags and that cobra Args switches to MinimumNArgs. +func TestBuildDynamicCommands_PositionalArg(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "text": { + Positional: true, + PositionalIndex: 0, + }, + "conversationId": { + Alias: "conv", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + if f := send.Flags().Lookup("text"); f != nil { + t.Fatalf("text should be positional, not flag, but got flag with usage=%q", f.Usage) + } + // Non-positional flag should still be present. + if f := send.Flags().Lookup("conv"); f == nil { + t.Fatal("--conv flag should still be registered") + } + // Validate arity: executing with zero args must fail with cobra's arity error. + // Execute() walks up to the root, so set args on the root command. + cmds[0].SetArgs([]string{"send"}) + cmds[0].SilenceUsage = true + cmds[0].SilenceErrors = true + send.SilenceUsage = true + send.SilenceErrors = true + err := cmds[0].Execute() + if err == nil { + t.Fatal("expected error when required positional is missing") + } + if !strings.Contains(err.Error(), "arg") { + t.Fatalf("expected arity error, got %v", err) + } +} + +// TestBuildDynamicCommands_PositionalArgInjection verifies that positional +// args are injected into params[property] when the leaf is invoked. +func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) { + t.Parallel() + + captured := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "text": { + Positional: true, + PositionalIndex: 0, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, captured, nil) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + if err := send.RunE(send, []string{"hello world"}); err != nil { + t.Fatalf("runE: %v", err) + } + if captured.lastParams["text"] != "hello world" { + t.Fatalf("expected params[text]='hello world', got %+v", captured.lastParams) + } +} + +// TestBuildDynamicCommands_PositionalWithFlagAliases verifies that an +// envelope binding marked Positional + Alias + Aliases registers visible +// primary + hidden alias flags AND still accepts the value as a positional +// arg. Mirrors the dws devdoc article search shape: +// +// { keyword: { alias: "query", aliases: ["keyword"], positional: true } } +func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) { + t.Parallel() + + build := func() (*cobra.Command, *captureRunner) { + captured := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-devdoc", + CLI: market.CLIOverlay{ + ID: "devdoc", + Command: "devdoc", + Groups: map[string]market.CLIGroupDef{ + "article": {Description: "文档文章"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + "search_open_platform_docs": { + CLIName: "search", + Group: "article", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + Aliases: []string{"keyword"}, + Required: true, + Positional: true, + PositionalIndex: 0, + Description: "搜索关键词 (必填)", + }, + }, + }, + }, + }, + }, + } + cmds := BuildDynamicCommands(servers, captured, nil) + article := findChild(cmds[0], "article") + if article == nil { + t.Fatal("article group not found") + } + search := findChild(article, "search") + if search == nil { + t.Fatal("search leaf not found") + } + return search, captured + } + + t.Run("flag registration: visible query + hidden keyword", func(t *testing.T) { + t.Parallel() + search, _ := build() + queryFlag := search.Flags().Lookup("query") + if queryFlag == nil { + t.Fatal("--query flag should be registered for dual-mode positional") + } + if queryFlag.Hidden { + t.Fatal("--query flag should be visible (not hidden)") + } + keywordFlag := search.Flags().Lookup("keyword") + if keywordFlag == nil { + t.Fatal("--keyword hidden alias flag should be registered") + } + if !keywordFlag.Hidden { + t.Fatal("--keyword alias flag should be hidden") + } + }) + + t.Run("invocation via positional", func(t *testing.T) { + t.Parallel() + search, captured := build() + if err := search.RunE(search, []string{"MCP"}); err != nil { + t.Fatalf("RunE positional: %v", err) + } + if captured.lastParams["keyword"] != "MCP" { + t.Fatalf("positional: keyword = %v, want MCP", captured.lastParams["keyword"]) + } + }) + + t.Run("invocation via --query primary flag", func(t *testing.T) { + t.Parallel() + search, captured := build() + if err := search.Flags().Set("query", "MCP"); err != nil { + t.Fatalf("Set --query: %v", err) + } + if err := search.RunE(search, nil); err != nil { + t.Fatalf("RunE --query: %v", err) + } + if captured.lastParams["keyword"] != "MCP" { + t.Fatalf("--query: keyword = %v, want MCP", captured.lastParams["keyword"]) + } + }) + + t.Run("invocation via --keyword hidden alias", func(t *testing.T) { + t.Parallel() + search, captured := build() + if err := search.Flags().Set("keyword", "MCP"); err != nil { + t.Fatalf("Set --keyword: %v", err) + } + if err := search.RunE(search, nil); err != nil { + t.Fatalf("RunE --keyword: %v", err) + } + if captured.lastParams["keyword"] != "MCP" { + t.Fatalf("--keyword: keyword = %v, want MCP", captured.lastParams["keyword"]) + } + }) + + t.Run("flag wins over positional when both supplied", func(t *testing.T) { + t.Parallel() + search, captured := build() + if err := search.Flags().Set("query", "FROM_FLAG"); err != nil { + t.Fatalf("Set --query: %v", err) + } + if err := search.RunE(search, []string{"FROM_POSITIONAL"}); err != nil { + t.Fatalf("RunE both: %v", err) + } + if captured.lastParams["keyword"] != "FROM_FLAG" { + t.Fatalf("flag should win: keyword = %v, want FROM_FLAG", captured.lastParams["keyword"]) + } + }) + + t.Run("missing input returns validation error", func(t *testing.T) { + t.Parallel() + search, _ := build() + err := search.RunE(search, nil) + if err == nil { + t.Fatal("expected validation error when neither flag nor positional was supplied") + } + msg := err.Error() + if !strings.Contains(msg, "--query") || !strings.Contains(msg, "keyword") { + t.Fatalf("error message %q should reference both --query and keyword", msg) + } + }) + + t.Run("arity allows zero args (relaxed for dual-mode)", func(t *testing.T) { + t.Parallel() + search, _ := build() + // Args validator should accept zero args because --query / --keyword + // can satisfy the requirement; the validation step in RunE catches + // the truly-missing case. + if err := search.Args(search, []string{}); err != nil { + t.Fatalf("Args([]) should not error for dual-mode positional: %v", err) + } + if err := search.Args(search, []string{"MCP"}); err != nil { + t.Fatalf("Args([MCP]) should not error: %v", err) + } + if err := search.Args(search, []string{"MCP", "extra"}); err == nil { + t.Fatal("Args should cap at totalMax=1, extra arg should error") + } + }) +} + +// TestBuildDynamicCommands_PositionalArityMixed verifies that mixing pure +// positional (required) with dual-mode positional (with flag aliases) yields +// a RangeArgs validator: pure positional enforces the minimum, dual-mode +// extends the maximum. +func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-mixed", + CLI: market.CLIOverlay{ + ID: "mixed", + Command: "mixed", + ToolOverrides: map[string]market.CLIToolOverride{ + "do_thing": { + CLIName: "do", + Flags: map[string]market.CLIFlagOverride{ + // pure positional, required: enforces MinimumNArgs(1) + "target": { + Positional: true, + PositionalIndex: 0, + Required: true, + }, + // dual-mode positional at slot 1: extends totalMax to 2 + "label": { + Alias: "label", + Aliases: []string{"name"}, + Positional: true, + PositionalIndex: 1, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + leaf := findChild(cmds[0], "do") + if leaf == nil { + t.Fatal("do leaf not found") + } + + // 0 args → arity error (pure positional 'target' missing) + if err := leaf.Args(leaf, []string{}); err == nil { + t.Fatal("Args([]) should error: pure positional 'target' is required") + } + // 1 arg → fills target, label can come from --label flag + if err := leaf.Args(leaf, []string{"T"}); err != nil { + t.Fatalf("Args([T]) should succeed: %v", err) + } + // 2 args → both positionals satisfied + if err := leaf.Args(leaf, []string{"T", "L"}); err != nil { + t.Fatalf("Args([T, L]) should succeed: %v", err) + } + // 3 args → exceeds totalMax=2 + if err := leaf.Args(leaf, []string{"T", "L", "extra"}); err == nil { + t.Fatal("Args([T, L, extra]) should error: totalMax=2") + } + + // 1 arg + --label flag should satisfy the validation step in RunE. + if err := leaf.Flags().Set("label", "L_FROM_FLAG"); err != nil { + t.Fatalf("Set --label: %v", err) + } + if err := leaf.RunE(leaf, []string{"T"}); err != nil { + t.Fatalf("RunE([T]) with --label flag: %v", err) + } +} + +// TestBuildDynamicCommands_ServerOverride verifies ServerOverride routes +// the tool invocation's CanonicalProduct to a different product. +func TestBuildDynamicCommands_ServerOverride(t *testing.T) { + t.Parallel() + + captured := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_bots": { + CLIName: "bot-list", + ServerOverride: "bot", + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, captured, nil) + leaf := findChild(cmds[0], "bot-list") + if leaf == nil { + t.Fatal("bot-list leaf not found") + } + if err := leaf.RunE(leaf, nil); err != nil { + t.Fatalf("runE: %v", err) + } + if captured.lastProduct != "bot" { + t.Fatalf("expected CanonicalProduct=bot, got %q", captured.lastProduct) + } + if captured.lastTool != "list_bots" { + t.Fatalf("expected tool=list_bots, got %q", captured.lastTool) + } +} + +// TestBuildDynamicCommands_ServerOverrideFallback verifies ServerOverride +// falls back to cli.ID when left empty (backwards compat with existing configs). +func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) { + t.Parallel() + + captured := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_conversations": {CLIName: "list"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, captured, nil) + leaf := findChild(cmds[0], "list") + if leaf == nil { + t.Fatal("list leaf not found") + } + if err := leaf.RunE(leaf, nil); err != nil { + t.Fatalf("runE: %v", err) + } + if captured.lastProduct != "chat" { + t.Fatalf("expected CanonicalProduct=chat (fallback), got %q", captured.lastProduct) + } +} + +// TestBuildDynamicCommands_DescriptionOverridesUsage verifies that overlay +// Description wins over the default paramName usage text. +func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "conversationId": { + Alias: "conv", + Description: "Target conversation open ID", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + f := send.Flags().Lookup("conv") + if f == nil { + t.Fatal("--conv flag missing") + } + if f.Usage != "Target conversation open ID" { + t.Fatalf("expected custom description, got %q", f.Usage) + } +} + +// TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema verifies that when +// the Detail API schema and overlay both define the same param, the overlay's +// Description survives enrichment. +func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message": { + CLIName: "send", + Flags: map[string]market.CLIFlagOverride{ + "conversationId": { + Alias: "conv", + Description: "Overlay wins", + }, + }, + }, + }, + }, + }, + } + + details := map[string][]market.DetailTool{ + "chat": { + { + ToolName: "send_message", + ToolRequest: `{"properties":{` + + `"conversationId":{"type":"string","description":"Schema description"}` + + `},"required":["conversationId"]}`, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details) + send := findChild(cmds[0], "send") + if send == nil { + t.Fatal("send leaf not found") + } + f := send.Flags().Lookup("conv") + if f == nil { + t.Fatal("--conv flag missing") + } + if f.Usage != "Overlay wins" { + t.Fatalf("expected overlay description to win, got %q", f.Usage) + } +} + +// Phase 5 P2 schema extensions ---------------------------------------------- + +// TestBuildDynamicCommands_BodyWrapper verifies that bodyWrapper wraps all +// user-facing params under the named key while keeping internal control +// keys (prefix '_') at the top level. +func TestBuildDynamicCommands_BodyWrapper(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-todo", + CLI: market.CLIOverlay{ + ID: "todo", + Command: "todo", + ToolOverrides: map[string]market.CLIToolOverride{ + "create_todo": { + CLIName: "create", + BodyWrapper: "PersonalTodoCreateVO", + Flags: map[string]market.CLIFlagOverride{ + "subject": {Alias: "subject", Required: true}, + "dueTime": {Alias: "due", Transform: "iso8601_to_millis"}, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + create := findChild(cmds[0], "create") + if create == nil { + t.Fatal("create leaf not found") + } + + cmds[0].SetArgs([]string{"create", "--subject", "buy milk", "--due", "2026-05-01"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + + wrap, ok := runner.lastParams["PersonalTodoCreateVO"].(map[string]any) + if !ok { + t.Fatalf("expected params wrapped under PersonalTodoCreateVO, got %+v", runner.lastParams) + } + if wrap["subject"] != "buy milk" { + t.Fatalf("wrap[subject]=%v, want 'buy milk'", wrap["subject"]) + } + if due, ok := wrap["dueTime"].(int64); !ok || due == 0 { + t.Fatalf("wrap[dueTime] must be int64 millis (iso8601_to_millis), got %T %v", wrap["dueTime"], wrap["dueTime"]) + } + if _, exists := runner.lastParams["subject"]; exists { + t.Fatalf("bodyWrapper must move 'subject' off the top level, got %+v", runner.lastParams) + } +} + +// TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys verifies that +// keys starting with '_' stay at the top level so sensitive / _blocked / +// _yes style confirmation plumbing keeps working. +func TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys(t *testing.T) { + t.Parallel() + + params := map[string]any{ + "subject": "x", + "_blocked": true, + "_yes": false, + } + wrapParamsIntoBody(params, "Body") + body, ok := params["Body"].(map[string]any) + if !ok { + t.Fatalf("expected Body wrapper, got %+v", params) + } + if body["subject"] != "x" { + t.Fatalf("body[subject]=%v, want 'x'", body["subject"]) + } + if _, has := body["_blocked"]; has { + t.Fatal("internal _blocked must not be moved into body") + } + if _, has := params["_blocked"]; !has { + t.Fatal("_blocked must remain at top level") + } +} + +// TestBuildDynamicCommands_MutuallyExclusive verifies that cobra refuses to +// run when two flags from a mutually-exclusive group are set together. +func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_messages": { + CLIName: "list", + Flags: map[string]market.CLIFlagOverride{ + "groupId": {Alias: "group"}, + "userId": {Alias: "user"}, + "openDingtalkId": {Alias: "open-dingtalk-id"}, + }, + MutuallyExclusive: [][]string{ + {"group", "user", "open-dingtalk-id"}, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + err := cmds[0].Execute() + if err == nil { + t.Fatal("expected mutually-exclusive error, got nil") + } + // cobra 1.10+ prints "if any flags in the group [...] are set none of the others can be"; + // earlier versions used "mutually exclusive". Accept either wording. + msg := err.Error() + if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") { + t.Fatalf("expected mutually-exclusive error, got %v", err) + } +} + +// TestBuildDynamicCommands_RequireOneOf verifies that cobra refuses to run +// when none of the required-one-of flags are set. +func TestBuildDynamicCommands_RequireOneOf(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_messages": { + CLIName: "list", + Flags: map[string]market.CLIFlagOverride{ + "groupId": {Alias: "group"}, + "userId": {Alias: "user"}, + }, + RequireOneOf: [][]string{ + {"group", "user"}, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + cmds[0].SetArgs([]string{"list"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + err := cmds[0].Execute() + if err == nil { + t.Fatal("expected require-one-of error, got nil") + } + if !strings.Contains(err.Error(), "required") && !strings.Contains(err.Error(), "one") { + t.Fatalf("expected required-one-of error, got %v", err) + } +} + +// TestBuildDynamicCommands_RequireOneOfSatisfied verifies that setting one +// of the required flags lets the command run normally. +func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_messages": { + CLIName: "list", + Flags: map[string]market.CLIFlagOverride{ + "groupId": {Alias: "group"}, + "userId": {Alias: "user"}, + }, + RequireOneOf: [][]string{{"group", "user"}}, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"list", "--group", "g1"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.lastParams["groupId"] != "g1" { + t.Fatalf("expected groupId=g1, got %+v", runner.lastParams) + } +} + +// TestBuildDynamicCommands_RedirectTo verifies redirectTo replaces the leaf +// with a stub that only prints the redirect target. +func TestBuildDynamicCommands_RedirectTo(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "old_history": { + CLIName: "history", + RedirectTo: "dws chat message list", + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + history := findChild(cmds[0], "history") + if history == nil { + t.Fatal("history stub not found") + } + out := &strings.Builder{} + history.SetOut(out) + history.SetErr(out) + if err := history.RunE(history, nil); err != nil { + t.Fatalf("runE: %v", err) + } + if !strings.Contains(out.String(), "dws chat message list") { + t.Fatalf("redirect output missing target, got %q", out.String()) + } + if runner.lastTool != "" { + t.Fatalf("redirect must not call a tool, got %q", runner.lastTool) + } + // Redirect stubs disable flag parsing, so arbitrary args must not error. + if history.Flags().Lookup("json") != nil { + t.Fatal("redirect stub must not register --json / --params flags") + } +} + +// TestBuildDynamicCommands_Hints verifies cli.hintCommands creates a stub +// sub-command under the overlay root (or under a named group) that prints +// the canonical path instead of invoking a tool. +func TestBuildDynamicCommands_Hints(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + Groups: map[string]market.CLIGroupDef{ + "message": {Description: "会话消息"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + "list_conversations": {CLIName: "list", Group: "message"}, + }, + Hints: map[string]market.CLIHintDef{ + "history": { + Target: "dws chat message list", + Description: "migrated to `message list`", + }, + "purge": { + Target: "dws chat message delete-all", + Group: "message", + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + root := cmds[0] + + // history hint attached directly under root. + history := findChild(root, "history") + if history == nil { + t.Fatal("hint 'history' missing under chat root") + } + if history.Short != "migrated to `message list`" { + t.Fatalf("hint description not applied, got %q", history.Short) + } + + // purge hint nested under message group. + msg := findChild(root, "message") + if msg == nil { + t.Fatal("message group missing") + } + purge := findChild(msg, "purge") + if purge == nil { + t.Fatal("hint 'purge' missing under message group") + } + + out := &strings.Builder{} + purge.SetOut(out) + if err := purge.RunE(purge, nil); err != nil { + t.Fatalf("runE: %v", err) + } + if !strings.Contains(out.String(), "dws chat message delete-all") { + t.Fatalf("hint output missing target, got %q", out.String()) + } +} + +// TestBuildDynamicCommands_UnknownFlagConstraintSkipped verifies that a +// stale / malformed mutuallyExclusive referencing an unknown flag is logged +// and skipped rather than blocking command tree construction. +func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_messages": { + CLIName: "list", + Flags: map[string]market.CLIFlagOverride{ + "groupId": {Alias: "group"}, + }, + MutuallyExclusive: [][]string{ + {"group", "does-not-exist"}, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + list := findChild(cmds[0], "list") + if list == nil { + t.Fatal("list leaf not found (constraint validation must not abort build)") + } + cmds[0].SetArgs([]string{"list", "--group", "g1"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("command should run despite skipped constraint, got %v", err) + } +} + +// TestBuildDynamicCommands_MultipleAliases_PrimarySet verifies the primary +// flag still works when a binding declares extra hidden aliases. +func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_contact_by_key_word": { + CLIName: "search", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + Aliases: []string{"keyword"}, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"search", "--query", "hello"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.lastParams["keyword"] != "hello" { + t.Fatalf("expected keyword=hello, got %+v", runner.lastParams) + } + + // The hidden alias must exist on the leaf command but be marked hidden. + leaf := findChild(cmds[0], "search") + if leaf == nil { + t.Fatal("search leaf missing") + } + alias := leaf.Flags().Lookup("keyword") + if alias == nil { + t.Fatal("hidden alias --keyword not registered") + } + if !alias.Hidden { + t.Fatalf("--keyword must be hidden (got Hidden=false)") + } + if primary := leaf.Flags().Lookup("query"); primary == nil || primary.Hidden { + t.Fatalf("--query must be registered and visible (got %+v)", primary) + } +} + +// TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet verifies that +// passing only a hidden alias satisfies Required and routes the value to +// params[Property]. Regression: pre-fix envelope rejected --keyword with +// "unknown flag". +func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_contact_by_key_word": { + CLIName: "search", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + Aliases: []string{"keyword"}, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"search", "--keyword", "hi"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute with hidden alias: %v", err) + } + if runner.lastParams["keyword"] != "hi" { + t.Fatalf("expected keyword=hi, got %+v", runner.lastParams) + } +} + +// TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet verifies +// the self-check fallback: when Required binding has aliases but none are +// supplied, CollectBindings emits "-- is required". +func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_contact_by_key_word": { + CLIName: "search", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + Aliases: []string{"keyword"}, + Required: true, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"search"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + err := cmds[0].Execute() + if err == nil { + t.Fatal("expected required error, got nil") + } + if !strings.Contains(err.Error(), "--query is required") { + t.Fatalf("expected --query is required, got %v", err) + } +} + +// TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet verifies +// that when both the primary and an alias are provided on the CLI, the +// primary wins (matches cmdutil.FlagOrFallback precedence: primary first). +func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_contact_by_key_word": { + CLIName: "search", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + Aliases: []string{"keyword"}, + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.lastParams["keyword"] != "primary" { + t.Fatalf("expected primary to win (keyword=primary), got %+v", runner.lastParams) + } +} + +// TestBuildDynamicCommands_MultipleAliases_MultiAliasChain verifies a chain +// of 3+ aliases resolves the value from the first one that is set. +func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "get_user_info_by_user_ids": { + CLIName: "get", + Flags: map[string]market.CLIFlagOverride{ + "user_id_list": { + Alias: "ids", + Aliases: []string{"user-id", "user-ids"}, + Required: true, + Transform: "csv_to_array", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute with --user-ids: %v", err) + } + got, ok := runner.lastParams["user_id_list"].([]any) + if !ok { + t.Fatalf("expected []any for user_id_list after csv_to_array, got %T (%+v)", runner.lastParams["user_id_list"], runner.lastParams) + } + if len(got) != 2 || got[0] != "u1" || got[1] != "u2" { + t.Fatalf("expected [u1 u2], got %+v", got) + } + + // All three hidden aliases must be registered and hidden, primary visible. + leaf := findChild(cmds[0], "get") + if leaf == nil { + t.Fatal("get leaf missing") + } + for _, name := range []string{"user-id", "user-ids"} { + f := leaf.Flags().Lookup(name) + if f == nil { + t.Fatalf("alias --%s not registered", name) + } + if !f.Hidden { + t.Fatalf("alias --%s must be hidden", name) + } + } + if p := leaf.Flags().Lookup("ids"); p == nil || p.Hidden { + t.Fatalf("primary --ids must be visible") + } +} + +// TestBuildDynamicCommands_MultipleAliases_Dedup verifies that reserved +// names ("json", "params"), duplicates of the primary, duplicates of the +// single Alias, and intra-slice duplicates are all silently skipped so +// cobra never double-registers a flag. +func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-contact", + CLI: market.CLIOverlay{ + ID: "contact", + Command: "contact", + ToolOverrides: map[string]market.CLIToolOverride{ + "search_contact_by_key_word": { + CLIName: "search", + Flags: map[string]market.CLIFlagOverride{ + "keyword": { + Alias: "query", + // Conflicts: query dup with alias, json/params are + // reserved, keyword appears twice. + Aliases: []string{"query", "json", "params", "keyword", "keyword"}, + }, + }, + }, + }, + }, + }, + } + + // If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd + // should build and execute fine. + cmds := BuildDynamicCommands(servers, runner, nil) + cmds[0].SetArgs([]string{"search", "--keyword", "ok"}) + cmds[0].SilenceErrors = true + cmds[0].SilenceUsage = true + if err := cmds[0].Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.lastParams["keyword"] != "ok" { + t.Fatalf("expected keyword=ok, got %+v", runner.lastParams) + } +} + func TestBuildDynamicCommands_NoParent(t *testing.T) { t.Parallel() @@ -143,3 +1419,529 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) { t.Fatalf("expected 2 top-level commands, got %d", len(cmds)) } } + +// envelope-help-alignment ---------------------------------------------------- +// +// The next four tests cover the envelope-driven --help upgrades so dws-wukong +// can ship hardcoded-equivalent --help text purely through Diamond config. + +// TestBuildDynamicCommands_ExampleField verifies CLIToolOverride.Example flows +// to cobra.Command.Example, surfacing the "Examples:" section in --help. +func TestBuildDynamicCommands_ExampleField(t *testing.T) { + t.Parallel() + + const want = " dws oa approval list-forms --cursor 0 --size 100" + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-oa", + CLI: market.CLIOverlay{ + ID: "oa", + Command: "oa", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_user_visible_process": { + CLIName: "list-forms", + Group: "approval", + Example: " dws oa approval list-forms --cursor 0 --size 100", + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + approval := findChild(cmds[0], "approval") + if approval == nil { + t.Fatal("approval group not found") + } + leaf := findChild(approval, "list-forms") + if leaf == nil { + t.Fatal("list-forms leaf not found") + } + if leaf.Example != want { + t.Fatalf("Example mismatch:\n want: %q\n got: %q", want, leaf.Example) + } + if !strings.Contains(leaf.UsageString(), "Examples:") { + t.Fatalf("expected 'Examples:' section in --help; usage:\n%s", leaf.UsageString()) + } +} + +// TestApplyBindings_VisibleFlagDefault_String verifies that +// CLIFlagOverride.Default flows to cobra String flags so --help shows +// (default "0"). Hidden-only behavior is unchanged; this is the path that was +// previously dead code for visible flags. +func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-oa", + CLI: market.CLIOverlay{ + ID: "oa", + Command: "oa", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_user_visible_process": { + CLIName: "list-forms", + Flags: map[string]market.CLIFlagOverride{ + "cursor": { + Alias: "cursor", + Type: "string", + Default: "0", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + leaf := findChild(cmds[0], "list-forms") + if leaf == nil { + t.Fatal("list-forms leaf not found") + } + f := leaf.Flags().Lookup("cursor") + if f == nil { + t.Fatal("--cursor flag missing") + } + if f.DefValue != "0" { + t.Fatalf("expected DefValue=\"0\", got %q", f.DefValue) + } + usage := leaf.UsageString() + if !strings.Contains(usage, `(default "0")`) { + t.Fatalf("expected --help to contain (default \"0\"); got:\n%s", usage) + } +} + +// TestApplyBindings_VisibleFlagDefault_Int verifies the Int kind path: cobra +// renders int defaults without quotes, so we look for "(default 100)". +func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-oa", + CLI: market.CLIOverlay{ + ID: "oa", + Command: "oa", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_user_visible_process": { + CLIName: "list-forms", + Flags: map[string]market.CLIFlagOverride{ + "pageSize": { + Alias: "size", + Type: "int", + Default: "100", + }, + }, + }, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + leaf := findChild(cmds[0], "list-forms") + if leaf == nil { + t.Fatal("list-forms leaf not found") + } + f := leaf.Flags().Lookup("size") + if f == nil { + t.Fatal("--size flag missing") + } + if f.DefValue != "100" { + t.Fatalf("expected DefValue=\"100\", got %q", f.DefValue) + } + if !strings.Contains(leaf.UsageString(), "(default 100)") { + t.Fatalf("expected --help to contain (default 100); got:\n%s", leaf.UsageString()) + } +} + +// TestCollectBindings_DefaultDoesNotImplyChanged guards the lower half of +// the v3.2 contract: CollectBindings itself must not infer "user changed +// this flag" from the presence of an envelope Default. Default-driven MCP +// body injection lives in buildOverrideBindings' normalizer (verified by the +// TestNormalizer_* tests below) so user-vs-default attribution stays clean +// — `firstChangedFlag` is the single source of truth for "user typed it". +func TestCollectBindings_DefaultDoesNotImplyChanged(t *testing.T) { + t.Parallel() + + bindings := []FlagBinding{ + { + FlagName: "cursor", + Property: "cursor", + Kind: ValueString, + Default: "0", + Usage: "page cursor", + }, + } + cmd := &cobra.Command{Use: "list-forms"} + ApplyBindings(cmd, bindings) + + // Sanity: cobra received the default and would render it in --help. + if got := cmd.Flags().Lookup("cursor").DefValue; got != "0" { + t.Fatalf("expected DefValue=\"0\" wired to cobra, got %q", got) + } + + // Don't call cmd.SetArgs / cmd.Execute — i.e. user typed nothing. + params, err := CollectBindings(cmd, bindings, nil) + if err != nil { + t.Fatalf("CollectBindings returned error: %v", err) + } + if _, ok := params["cursor"]; ok { + t.Fatalf("default-only flag must not appear in MCP params; got %v", params) + } +} + +// TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted is the v3.2 mirror of +// the test above: CollectBindings still skips, but the normalizer returned +// by buildOverrideBindings must inject the envelope default for visible +// flags so the MCP body matches the hardcoded helper command's behavior +// (`mustGetFlag(cobra default) → body`). This is the regression that broke +// `dws oa approval list-forms` when the user omitted --cursor / --size. +func TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted(t *testing.T) { + t.Parallel() + + override := market.CLIToolOverride{ + CLIName: "list-forms", + Flags: map[string]market.CLIFlagOverride{ + "cursor": { + Alias: "cursor", + Type: "int", + Default: "0", + }, + "pageSize": { + Alias: "size", + Type: "int", + Default: "100", + }, + }, + } + + bindings, normalizer := buildOverrideBindings(override) + if normalizer == nil { + t.Fatal("expected non-nil normalizer when defaults are present") + } + cmd := &cobra.Command{Use: "list-forms"} + ApplyBindings(cmd, bindings) + + params, err := CollectBindings(cmd, bindings, nil) + if err != nil { + t.Fatalf("CollectBindings returned error: %v", err) + } + if err := normalizer(cmd, params); err != nil { + t.Fatalf("normalizer returned error: %v", err) + } + + if got, ok := params["cursor"].(int); !ok || got != 0 { + t.Fatalf("expected params[cursor] = int(0), got %T(%v)", params["cursor"], params["cursor"]) + } + if got, ok := params["pageSize"].(int); !ok || got != 100 { + t.Fatalf("expected params[pageSize] = int(100), got %T(%v)", params["pageSize"], params["pageSize"]) + } +} + +// TestNormalizer_DefaultCoercedByKind covers every ValueKind the envelope +// type-name dictionary (kindFromTypeName) can actually produce today — +// string / int / bool / string_slice — so a number-typed schema receives a +// number (not the raw envelope string) and slice kinds get a []string. +// Catches regressions in the parseFlagDefault dispatch. +// +// ValueFloat and the *_slice numeric kinds aren't reachable from envelope +// `type` strings yet, but the normalizer still handles them for forward +// compatibility (asserted indirectly by the build). +func TestNormalizer_DefaultCoercedByKind(t *testing.T) { + t.Parallel() + + override := market.CLIToolOverride{ + CLIName: "demo", + Flags: map[string]market.CLIFlagOverride{ + "name": {Alias: "name", Type: "string", Default: "alice"}, + "page": {Alias: "page", Type: "int", Default: "7"}, + "verbose": {Alias: "verbose", Type: "bool", Default: "true"}, + "tags": {Alias: "tags", Type: "string_slice", Default: "a,b,c"}, + }, + } + + bindings, normalizer := buildOverrideBindings(override) + if normalizer == nil { + t.Fatal("expected non-nil normalizer when defaults are present") + } + cmd := &cobra.Command{Use: "demo"} + ApplyBindings(cmd, bindings) + + params, err := CollectBindings(cmd, bindings, nil) + if err != nil { + t.Fatalf("CollectBindings returned error: %v", err) + } + if err := normalizer(cmd, params); err != nil { + t.Fatalf("normalizer returned error: %v", err) + } + + if got, ok := params["name"].(string); !ok || got != "alice" { + t.Fatalf("name: expected string \"alice\", got %T(%v)", params["name"], params["name"]) + } + if got, ok := params["page"].(int); !ok || got != 7 { + t.Fatalf("page: expected int 7, got %T(%v)", params["page"], params["page"]) + } + if got, ok := params["verbose"].(bool); !ok || !got { + t.Fatalf("verbose: expected bool true, got %T(%v)", params["verbose"], params["verbose"]) + } + got, ok := params["tags"].([]string) + if !ok { + t.Fatalf("tags: expected []string, got %T(%v)", params["tags"], params["tags"]) + } + if strings.Join(got, ",") != "a,b,c" { + t.Fatalf("tags: expected [a b c], got %v", got) + } +} + +// TestNormalizer_DefaultDoesNotOverrideUserValue locks in the +// `if exists continue` guard so user input always beats the envelope default, +// matching CollectBindings' user-changed-flag wins contract. +func TestNormalizer_DefaultDoesNotOverrideUserValue(t *testing.T) { + t.Parallel() + + override := market.CLIToolOverride{ + CLIName: "list-forms", + Flags: map[string]market.CLIFlagOverride{ + "cursor": { + Alias: "cursor", + Type: "int", + Default: "0", + }, + }, + } + + bindings, normalizer := buildOverrideBindings(override) + if normalizer == nil { + t.Fatal("expected non-nil normalizer when defaults are present") + } + cmd := &cobra.Command{Use: "list-forms"} + ApplyBindings(cmd, bindings) + + if err := cmd.ParseFlags([]string{"--cursor", "5"}); err != nil { + t.Fatalf("ParseFlags returned error: %v", err) + } + + params, err := CollectBindings(cmd, bindings, nil) + if err != nil { + t.Fatalf("CollectBindings returned error: %v", err) + } + if err := normalizer(cmd, params); err != nil { + t.Fatalf("normalizer returned error: %v", err) + } + + if got, ok := params["cursor"].(int); !ok || got != 5 { + t.Fatalf("expected params[cursor] = int(5), got %T(%v)", params["cursor"], params["cursor"]) + } +} + +// parent-merge tests --------------------------------------------------------- + +// TestBuildDynamicCommands_ParentMergeSameName covers the case where two +// servers share the same cli.command + cli.parent. Instead of producing two +// sibling subcommands with the same Name under the parent (which cobra allows +// but `--help` renders as duplicate rows), the compat layer merges them into +// a single subcommand whose children are the union of both sides. +func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "group-chat", + Command: "chat", + Groups: map[string]market.CLIGroupDef{ + "message": {Description: "会话消息管理"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message_as_user": {CLIName: "send", Group: "message"}, + "list_conversation_message_v2": {CLIName: "list", Group: "message"}, + }, + }, + }, + { + // Second server contributes more leaves into the same "message" + // namespace via command="message" + parent="chat". + Endpoint: "https://endpoint-bot", + CLI: market.CLIOverlay{ + ID: "bot-message", + Command: "message", + Parent: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_robot_group_message": {CLIName: "send-by-bot"}, + "send_message_by_custom_robot": {CLIName: "send-by-webhook"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + if len(cmds) != 1 || cmds[0].Name() != "chat" { + t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds)) + } + + // There must be exactly one child named "message" under chat, not two. + var messageCmds []*cobra.Command + for _, sub := range cmds[0].Commands() { + if sub.Name() == "message" { + messageCmds = append(messageCmds, sub) + } + } + if len(messageCmds) != 1 { + names := make([]string, 0, len(cmds[0].Commands())) + for _, c := range cmds[0].Commands() { + names = append(names, c.Name()) + } + t.Fatalf("expected exactly one 'message' under chat, got %d — chat children: %v", len(messageCmds), names) + } + + // The merged message subcommand must contain all four leaves. + want := map[string]bool{"send": false, "list": false, "send-by-bot": false, "send-by-webhook": false} + for _, leaf := range messageCmds[0].Commands() { + if _, ok := want[leaf.Name()]; ok { + want[leaf.Name()] = true + } + } + for leaf, seen := range want { + if !seen { + t.Errorf("expected 'chat message %s' after merge, missing", leaf) + } + } +} + +// TestBuildDynamicCommands_ParentMergeRecursive covers a multi-level merge: +// chat already has `group.members` (with add/remove), and a separate +// bot-group server contributes `dws chat group members add-bot` via +// command=group + parent=chat + groups.members. The "group" and "members" +// nodes must each be merged, not duplicated. +func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "group-chat", + Command: "chat", + Groups: map[string]market.CLIGroupDef{ + "group": {Description: "群组管理"}, + "group.members": {Description: "群成员管理"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + "add_group_member": {CLIName: "add", Group: "group.members"}, + "remove_group_member": {CLIName: "remove", Group: "group.members"}, + }, + }, + }, + { + Endpoint: "https://endpoint-bot", + CLI: market.CLIOverlay{ + ID: "bot-group", + Command: "group", + Parent: "chat", + Groups: map[string]market.CLIGroupDef{ + "members": {Description: "机器人群成员"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + "add_robot_to_group": {CLIName: "add-bot", Group: "members"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + if len(cmds) != 1 || cmds[0].Name() != "chat" { + t.Fatalf("expected single top-level 'chat', got %d", len(cmds)) + } + + // One 'group' under chat. + var groupCmds []*cobra.Command + for _, sub := range cmds[0].Commands() { + if sub.Name() == "group" { + groupCmds = append(groupCmds, sub) + } + } + if len(groupCmds) != 1 { + t.Fatalf("expected single 'group' under chat, got %d", len(groupCmds)) + } + + // One 'members' under chat.group. + var membersCmds []*cobra.Command + for _, sub := range groupCmds[0].Commands() { + if sub.Name() == "members" { + membersCmds = append(membersCmds, sub) + } + } + if len(membersCmds) != 1 { + t.Fatalf("expected single 'members' under chat.group, got %d", len(membersCmds)) + } + + // The merged members subcommand must contain add, remove, add-bot. + want := map[string]bool{"add": false, "remove": false, "add-bot": false} + for _, leaf := range membersCmds[0].Commands() { + if _, ok := want[leaf.Name()]; ok { + want[leaf.Name()] = true + } + } + for leaf, seen := range want { + if !seen { + t.Errorf("expected 'chat group members %s', missing", leaf) + } + } +} + +// TestBuildDynamicCommands_ParentMergeLeafCollision verifies that when two +// servers both produce the same leaf path (e.g. both try to register +// `chat message send`), the first one wins and the second is silently +// dropped rather than producing a duplicate cobra command. +func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "group-chat", + Command: "chat", + Groups: map[string]market.CLIGroupDef{"message": {Description: "消息"}}, + ToolOverrides: map[string]market.CLIToolOverride{ + "send_message_as_user": {CLIName: "send", Group: "message"}, + }, + }, + }, + { + Endpoint: "https://endpoint-bot", + CLI: market.CLIOverlay{ + ID: "bot-message", + Command: "message", + Parent: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + // Intentional collision: same leaf name. + "send_robot_group_message": {CLIName: "send"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + if len(cmds) != 1 { + t.Fatalf("expected 1 top-level, got %d", len(cmds)) + } + messageCmd := findSubcommand(cmds[0], "message") + if messageCmd == nil { + t.Fatal("expected 'message' subcommand under chat") + } + // Exactly one 'send' leaf. + var sendCount int + for _, leaf := range messageCmd.Commands() { + if leaf.Name() == "send" { + sendCount++ + } + } + if sendCount != 1 { + t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount) + } +} diff --git a/internal/compat/hint_redirect.go b/internal/compat/hint_redirect.go new file mode 100644 index 00000000..12f3b8b0 --- /dev/null +++ b/internal/compat/hint_redirect.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// buildOverlayRedirect builds a top-level product command whose only behaviour +// is printing "Please use: dws " and returning nil. All args/flags are +// accepted and ignored so users running the old command path get the redirect +// message instead of a parse error. +// +// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo). +func buildOverlayRedirect(name, description, target string) *cobra.Command { + target = strings.TrimSpace(target) + short := strings.TrimSpace(description) + if short == "" { + if target != "" { + short = fmt.Sprintf("moved → %s", target) + } else { + short = "command relocated; see --help for the canonical path" + } + } + cmd := &cobra.Command{ + Use: name, + Short: short, + Long: fmt.Sprintf("This command has moved. Please use: dws %s", target), + DisableFlagParsing: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if target == "" { + _ = cmd.Help() + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target) + return nil + }, + } + return cmd +} diff --git a/internal/compat/output_format.go b/internal/compat/output_format.go new file mode 100644 index 00000000..91ebb101 --- /dev/null +++ b/internal/compat/output_format.go @@ -0,0 +1,107 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" +) + +// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping +// function applied to executor.Result.Response before the formatter runs. +// See discovery-schema-v3 §2.5. +// +// Apply order (deterministic): drop → rename → columns. +// Columns filter only takes effect under --format=table (the formatter +// consults it via the "_columns" marker key this function injects). +// Returns nil when the spec is empty so callers can skip wiring. +func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any { + if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 { + return nil + } + dropped := append([]string(nil), spec.Drop...) + renamed := make(map[string]string, len(spec.Rename)) + for k, v := range spec.Rename { + renamed[k] = v + } + columns := append([]string(nil), spec.Columns...) + + return func(resp map[string]any) map[string]any { + if resp == nil { + return resp + } + applyDrop(resp, dropped) + applyRename(resp, renamed) + if len(columns) > 0 { + resp["_columns"] = append([]string(nil), columns...) + } + return resp + } +} + +// applyDrop removes the named keys at the top level and one level of nested +// object. Missing keys are silently ignored. Keys with "." are treated as a +// two-part path (parent.child). +func applyDrop(m map[string]any, keys []string) { + for _, key := range keys { + if key == "" { + continue + } + delete(m, key) + } + for _, v := range m { + if inner, ok := v.(map[string]any); ok { + for _, key := range keys { + if key == "" { + continue + } + delete(inner, key) + } + } + } +} + +// applyRename moves fields from src key to dst key at top level and one level +// of nested object. Collisions overwrite silently. Missing src keys are +// no-ops. +func applyRename(m map[string]any, mapping map[string]string) { + if len(mapping) == 0 { + return + } + // First pass: top level. + for src, dst := range mapping { + if src == "" || dst == "" || src == dst { + continue + } + if v, ok := m[src]; ok { + m[dst] = v + delete(m, src) + } + } + // Second pass: one level nested. + for _, v := range m { + inner, ok := v.(map[string]any) + if !ok { + continue + } + for src, dst := range mapping { + if src == "" || dst == "" || src == dst { + continue + } + if val, ok := inner[src]; ok { + inner[dst] = val + delete(inner, src) + } + } + } +} diff --git a/internal/compat/registry.go b/internal/compat/registry.go index ccbd5b55..6b57fd15 100644 --- a/internal/compat/registry.go +++ b/internal/compat/registry.go @@ -56,11 +56,27 @@ type Target struct { type FlagBinding struct { FlagName string Alias string + // Aliases are additional hidden flag names that map to the same MCP + // parameter. Any of them being set satisfies Required, and the value + // is resolved via firstChangedFlag(FlagName, Alias, Aliases...). + // Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback. + Aliases []string Short string Property string Kind ValueKind Usage string Required bool + // Default is the cobra-level flag default value as a string. Parsed + // into the Kind-appropriate primitive at registration time. Empty + // string keeps the existing zero-value default. This only affects + // what cobra renders in --help (the "(default ...)" suffix); it does + // NOT inject the value into MCP params on its own — CollectBindings + // still gates writes by user-changed flags via firstChangedFlag. + Default string + // Positional binds this parameter to a positional CLI argument rather + // than a --flag. PositionalIndex is the 0-based slot. + Positional bool + PositionalIndex int } type Normalizer func(cmd *cobra.Command, params map[string]any) error @@ -75,6 +91,10 @@ type Route struct { Target Target Bindings []FlagBinding Normalizer Normalizer + // OutputTransform, when non-nil, post-processes the MCP response payload + // (rename / drop / columns) before the formatter emits it. Wired up from + // CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5. + OutputTransform func(map[string]any) map[string]any } type CommandFactory func(runner executor.Runner) *cobra.Command @@ -109,14 +129,87 @@ func NewFallbackCommands(runner executor.Runner) []*cobra.Command { var NewGroupCommand = cobracmd.NewGroupCommand func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command { + // Compute positional arity. Two counts: + // - totalMax: the highest PositionalIndex+1 across all positional bindings + // (caps how many trailing args cobra accepts). + // - strictMin: the highest PositionalIndex+1 among "pure" positional + // bindings (no flag aliases). Backward-compat: any pure positional + // binding implies required arity at parse time, regardless of Required. + // + // Dual-mode positional bindings (positional + envelope-declared flag + // aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`) + // are counted into totalMax but excluded from strictMin so a flag-only + // invocation parses; their required-presence is enforced by + // validateRequiredPositionalBindings inside RunE. + // + // For positional bindings, buildOverrideBindings populates FlagName / + // Aliases only when the envelope explicitly declared them, so the + // dual-mode detection here is unambiguous. + strictMin := 0 + totalMax := 0 + for _, b := range route.Bindings { + if !b.Positional { + continue + } + if b.PositionalIndex+1 > totalMax { + totalMax = b.PositionalIndex + 1 + } + hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0 + if !hasFlagAlias && b.PositionalIndex+1 > strictMin { + strictMin = b.PositionalIndex + 1 + } + } + var argsValidator cobra.PositionalArgs = cobra.NoArgs + switch { + case totalMax == 0: + argsValidator = cobra.NoArgs + case strictMin > 0 && strictMin == totalMax: + argsValidator = cobra.MinimumNArgs(strictMin) + case strictMin > 0: + argsValidator = cobra.RangeArgs(strictMin, totalMax) + default: + argsValidator = cobra.MaximumNArgs(totalMax) + } + + // Extend Use with [] tokens for positional bindings so + // `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded + // helper commands' style (e.g. devdoc article search [keyword]). + use := route.Use + if totalMax > 0 { + ordered := make([]FlagBinding, 0, totalMax) + for _, b := range route.Bindings { + if b.Positional { + ordered = append(ordered, b) + } + } + sort.SliceStable(ordered, func(i, j int) bool { + return ordered[i].PositionalIndex < ordered[j].PositionalIndex + }) + var sb strings.Builder + sb.WriteString(use) + for _, b := range ordered { + name := strings.TrimSpace(b.Property) + if name == "" { + name = strings.TrimSpace(b.FlagName) + } + if name == "" { + continue + } + sb.WriteString(" [") + sb.WriteString(name) + sb.WriteString("]") + } + use = sb.String() + } + cmd := &cobra.Command{ - Use: route.Use, + Use: use, Aliases: append([]string(nil), route.Aliases...), Short: route.Short, Long: route.Long, Example: route.Example, Hidden: route.Hidden, - Args: cobra.NoArgs, + Args: argsValidator, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { jsonPayload, err := cmd.Flags().GetString("json") @@ -141,10 +234,27 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command { params[key] = value } + // Inject positional args into params according to each binding's + // PositionalIndex. Pure positional bindings are not registered as + // flags; dual-mode positional bindings (positional + alias) only + // fall through to positional injection when their flag aliases + // were not used (collectPositionalBindings skips when params + // already contains the property). + if err := collectPositionalBindings(args, route.Bindings, params); err != nil { + return err + } + // Collect schema-derived flags (from buildFlagsFromDetailSchema) // that are not covered by explicit bindings. collectSchemaFlags(cmd, route.Bindings, params) + // Required-presence check for positional bindings — must run after + // both flag (CollectBindings) and positional (collectPositionalBindings) + // have had a chance to populate params. + if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil { + return err + } + if route.Normalizer != nil { if err := route.Normalizer(cmd, params); err != nil { return err @@ -180,6 +290,9 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command { if err != nil { return err } + if route.OutputTransform != nil && result.Response != nil { + result.Response = route.OutputTransform(result.Response) + } return output.WriteCommandPayload(cmd, result, output.FormatJSON) }, } @@ -196,8 +309,64 @@ func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command { return cmd } +// parseFlagDefault converts a string-form envelope default into the typed +// primitives used by pflag's *P helpers. Unparseable values silently fall +// back to the type's zero value so a malformed envelope downgrades to +// "no default in --help" rather than a panic at startup. The slice form +// splits on commas and trims whitespace, mirroring pflag.StringSlice +// behavior; empty/whitespace-only segments are dropped. +func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) { + trimmed := strings.TrimSpace(raw) + switch kind { + case ValueString, ValueJSON: + // Preserve raw (not trimmed) so explicitly-padded defaults survive. + defStr = raw + case ValueInt: + if trimmed != "" { + if v, err := strconv.Atoi(trimmed); err == nil { + defInt = v + } + } + case ValueFloat: + if trimmed != "" { + if v, err := strconv.ParseFloat(trimmed, 64); err == nil { + defFloat = v + } + } + case ValueBool: + if trimmed != "" { + if v, err := strconv.ParseBool(trimmed); err == nil { + defBool = v + } + } + case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: + if trimmed != "" { + for _, p := range strings.Split(trimmed, ",") { + if t := strings.TrimSpace(p); t != "" { + defSlice = append(defSlice, t) + } + } + } + } + return +} + func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) { for _, binding := range bindings { + // Positional bindings are collected from cobra args rather than flags. + // Exception: dual-mode bindings (positional + envelope-declared flag + // aliases) also register the aliases so users can pass either + // `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced + // later by validateRequiredPositionalBindings instead of MarkFlagRequired. + if binding.Positional { + primary := strings.TrimSpace(binding.FlagName) + alias := strings.TrimSpace(binding.Alias) + if primary == "" && alias == "" && len(binding.Aliases) == 0 { + continue + } + registerPositionalAliasFlags(cmd, binding) + continue + } primary := strings.TrimSpace(binding.FlagName) if primary == "" { continue @@ -206,43 +375,77 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) { if alias == primary { alias = "" } + // Dedupe extra aliases against primary + single alias and each other. + var extras []string + if len(binding.Aliases) > 0 { + seen := map[string]bool{primary: true, "json": true, "params": true} + if alias != "" { + seen[alias] = true + } + extras = make([]string, 0, len(binding.Aliases)) + for _, a := range binding.Aliases { + a = strings.TrimSpace(a) + if a == "" || seen[a] { + continue + } + seen[a] = true + extras = append(extras, a) + } + } + + // Parse binding.Default once per binding into Kind-typed values used + // by both the primary and hidden-alias registrations below. Hidden + // aliases share the same default so users typing the legacy alias + // see consistent --help text and zero-value behavior. + defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default) + + registerHidden := func(name string, suffix string) { + if name == "" { + return + } + switch binding.Kind { + case ValueString: + cmd.Flags().String(name, defStr, binding.Usage+suffix) + case ValueInt: + cmd.Flags().Int(name, defInt, binding.Usage+suffix) + case ValueFloat: + cmd.Flags().Float64(name, defFloat, binding.Usage+suffix) + case ValueBool: + cmd.Flags().Bool(name, defBool, binding.Usage+suffix) + case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: + cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix) + case ValueJSON: + cmd.Flags().String(name, defStr, binding.Usage+suffix) + } + _ = cmd.Flags().MarkHidden(name) + } switch binding.Kind { case ValueString: - cmd.Flags().StringP(primary, binding.Short, "", binding.Usage) - if alias != "" { - cmd.Flags().String(alias, "", binding.Usage+" (alias)") - _ = cmd.Flags().MarkHidden(alias) - } + cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage) case ValueInt: - cmd.Flags().IntP(primary, binding.Short, 0, binding.Usage) - if alias != "" { - cmd.Flags().Int(alias, 0, binding.Usage+" (alias)") - _ = cmd.Flags().MarkHidden(alias) - } + cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage) case ValueFloat: - cmd.Flags().Float64P(primary, binding.Short, 0, binding.Usage) - if alias != "" { - cmd.Flags().Float64(alias, 0, binding.Usage+" (alias)") - _ = cmd.Flags().MarkHidden(alias) - } + cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage) case ValueBool: - cmd.Flags().BoolP(primary, binding.Short, false, binding.Usage) - if alias != "" { - cmd.Flags().Bool(alias, false, binding.Usage+" (alias)") - _ = cmd.Flags().MarkHidden(alias) - } + cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage) case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: - cmd.Flags().StringSliceP(primary, binding.Short, nil, binding.Usage) - if alias != "" { - cmd.Flags().StringSlice(alias, nil, binding.Usage+" (alias)") - _ = cmd.Flags().MarkHidden(alias) - } + cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage) case ValueJSON: - cmd.Flags().StringP(primary, binding.Short, "", binding.Usage+" (JSON)") - if alias != "" { - cmd.Flags().String(alias, "", binding.Usage+" (alias, JSON)") - _ = cmd.Flags().MarkHidden(alias) + cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)") + } + registerHidden(alias, " (alias)") + for _, extra := range extras { + registerHidden(extra, " (alias)") + } + if binding.Required { + // When no hidden aliases exist, lean on cobra's native required + // validation for the best UX (colored error, shown in --help). + // When aliases exist, CollectBindings does its own "any-of-these + // is set" check so users who type the hidden alias do not hit + // cobra yelling about the primary being missing. + if alias == "" && len(extras) == 0 { + _ = cmd.MarkFlagRequired(primary) } } } @@ -252,6 +455,165 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) { _ = cmd.Flags().MarkHidden("params") } +// registerPositionalAliasFlags registers the visible primary flag and any +// hidden aliases for a "dual-mode" positional binding (envelope: +// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is +// intentionally deferred to validateRequiredPositionalBindings — cobra's +// MarkFlagRequired would yell even when the user supplied the value as a +// positional arg. +func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) { + primary := strings.TrimSpace(binding.FlagName) + alias := strings.TrimSpace(binding.Alias) + if alias == primary { + alias = "" + } + + // Dedupe extras against primary + alias and reserved internal names. + seen := map[string]bool{"json": true, "params": true} + if primary != "" { + seen[primary] = true + } + if alias != "" { + seen[alias] = true + } + extras := make([]string, 0, len(binding.Aliases)) + for _, a := range binding.Aliases { + a = strings.TrimSpace(a) + if a == "" || seen[a] { + continue + } + seen[a] = true + extras = append(extras, a) + } + + defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default) + + register := func(name string, withShort bool, hidden bool, usageSuffix string) { + if name == "" { + return + } + short := "" + if withShort { + short = binding.Short + } + usage := binding.Usage + usageSuffix + switch binding.Kind { + case ValueString: + cmd.Flags().StringP(name, short, defStr, usage) + case ValueInt: + cmd.Flags().IntP(name, short, defInt, usage) + case ValueFloat: + cmd.Flags().Float64P(name, short, defFloat, usage) + case ValueBool: + cmd.Flags().BoolP(name, short, defBool, usage) + case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: + cmd.Flags().StringSliceP(name, short, defSlice, usage) + case ValueJSON: + cmd.Flags().StringP(name, short, defStr, usage+" (JSON)") + default: + cmd.Flags().StringP(name, short, defStr, usage) + } + if hidden { + _ = cmd.Flags().MarkHidden(name) + } + } + + register(primary, true, false, "") + register(alias, false, true, " (alias)") + for _, e := range extras { + register(e, false, true, " (alias)") + } +} + +// collectPositionalBindings pulls positional args according to the bindings +// and injects them into params[property]. Missing slots are skipped (cobra +// arity validation already ran before RunE). +func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error { + for _, binding := range bindings { + if !binding.Positional { + continue + } + property := strings.TrimSpace(binding.Property) + if property == "" { + continue + } + // Dual-mode positional: if the user already provided the value via a + // flag alias (CollectBindings wrote it), honor flag > positional. + if _, ok := params[property]; ok { + continue + } + if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) { + continue + } + raw := args[binding.PositionalIndex] + switch binding.Kind { + case ValueInt: + v, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property)) + } + params[property] = v + case ValueFloat: + v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property)) + } + params[property] = v + case ValueBool: + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property)) + } + params[property] = v + default: + params[property] = raw + } + } + return nil +} + +// validateRequiredPositionalBindings enforces required-presence for positional +// bindings whose original envelope spec set required=true. The arity validator +// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs +// excluding the dual slot) so a flag-only invocation is permitted; this check +// closes the loop by rejecting the case where neither the positional arg nor +// any flag alias was supplied. +func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error { + for _, binding := range bindings { + if !binding.Positional || !binding.Required { + continue + } + property := strings.TrimSpace(binding.Property) + if property == "" { + continue + } + if v, ok := params[property]; ok { + if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" { + continue + } + } + // Compose candidate flag names so the error message points users at + // the first writable label even for flag-only invocations. + primary := strings.TrimSpace(binding.FlagName) + alias := strings.TrimSpace(binding.Alias) + if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed { + continue + } + display := primary + if display == "" { + display = alias + } + if display == "" && len(binding.Aliases) > 0 { + display = binding.Aliases[0] + } + if display == "" { + return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property)) + } + return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property)) + } + return nil +} + // collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that // have no explicit FlagBinding. This bridges the gap for plugin-defined tools // whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags. @@ -265,6 +627,11 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s if a := strings.TrimSpace(b.Alias); a != "" { bound[a] = true } + for _, extra := range b.Aliases { + if e := strings.TrimSpace(extra); e != "" { + bound[e] = true + } + } } // Reserved/internal flags that should never be forwarded as tool params. @@ -316,36 +683,78 @@ func toOriginalParamName(flagName string) string { return strings.ReplaceAll(flagName, "-", "_") } +// firstChangedFlag returns the first name (in order) whose cobra flag has +// been set by the user. Whitespace-only or empty entries are skipped. +// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias > +// extraAliases in declaration order. +func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) { + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + if cobracmd.FlagChanged(cmd, n) { + return n, true + } + } + return "", false +} + func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) { if existing == nil { existing = map[string]any{} } params := make(map[string]any) for _, binding := range bindings { + if binding.Positional { + // Pure positional (no flag aliases) is handled by + // collectPositionalBindings. Dual-mode positional bindings + // (envelope: positional + alias/aliases) fall through so any + // user-supplied flag value wins over the positional arg. + primary := strings.TrimSpace(binding.FlagName) + alias := strings.TrimSpace(binding.Alias) + if primary == "" && alias == "" && len(binding.Aliases) == 0 { + continue + } + } primaryName := strings.TrimSpace(binding.FlagName) if primaryName == "" { continue } aliasName := strings.TrimSpace(binding.Alias) - primaryChanged := cobracmd.FlagChanged(cmd, primaryName) - aliasChanged := aliasName != "" && cobracmd.FlagChanged(cmd, aliasName) - flagName := primaryName - if aliasChanged { - flagName = aliasName + // Candidate flag names in precedence order: primary, single alias, + // then extra aliases. Whichever is set first wins; mirrors the + // semantics of cmdutil.FlagOrFallback. + candidates := make([]string, 0, 2+len(binding.Aliases)) + candidates = append(candidates, primaryName) + if aliasName != "" && aliasName != primaryName { + candidates = append(candidates, aliasName) + } + for _, extra := range binding.Aliases { + e := strings.TrimSpace(extra) + if e == "" || e == primaryName || e == aliasName { + continue + } + candidates = append(candidates, e) + } + + flagName, anyChanged := firstChangedFlag(cmd, candidates...) + if !anyChanged { + flagName = primaryName } flag := cmd.Flags().Lookup(flagName) if flag == nil { continue } - if binding.Required && !primaryChanged && !aliasChanged { + if binding.Required && !anyChanged && !binding.Positional { if _, ok := existing[binding.Property]; ok { continue } return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName)) } - if !primaryChanged && !aliasChanged { + if !anyChanged { continue } diff --git a/internal/compat/registry_test.go b/internal/compat/registry_test.go index e2cae358..6c39d2fb 100644 --- a/internal/compat/registry_test.go +++ b/internal/compat/registry_test.go @@ -21,6 +21,71 @@ import ( "github.com/spf13/cobra" ) +func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) { + t.Parallel() + + // envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } } + bindings := []FlagBinding{ + { + FlagName: "query", + Aliases: []string{"keyword"}, + Property: "keyword", + Kind: ValueString, + Usage: "搜索关键词", + Required: true, + Positional: true, + PositionalIndex: 0, + }, + } + + cmd := &cobra.Command{Use: "search"} + ApplyBindings(cmd, bindings) + + primary := cmd.Flags().Lookup("query") + if primary == nil { + t.Fatal("--query flag should be registered for dual-mode positional") + } + if primary.Hidden { + t.Fatal("--query flag should be visible") + } + hidden := cmd.Flags().Lookup("keyword") + if hidden == nil { + t.Fatal("--keyword alias flag should be registered") + } + if !hidden.Hidden { + t.Fatal("--keyword alias flag should be hidden") + } + + // --query should NOT be marked required at cobra level — that would + // break flag-only invocation when arity is relaxed. + if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok { + t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)") + } +} + +func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) { + t.Parallel() + + // Pure positional (no Alias / no Aliases) → no flag should be registered; + // arity validator (set in NewDirectCommand) handles required-presence. + bindings := []FlagBinding{ + { + Property: "text", + Kind: ValueString, + Required: true, + Positional: true, + PositionalIndex: 0, + }, + } + + cmd := &cobra.Command{Use: "send"} + ApplyBindings(cmd, bindings) + + if f := cmd.Flags().Lookup("text"); f != nil { + t.Fatalf("pure positional should not register a flag, got %+v", f) + } +} + func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) { t.Parallel() diff --git a/internal/compat/transform.go b/internal/compat/transform.go index 75f2b99f..8f4ac389 100644 --- a/internal/compat/transform.go +++ b/internal/compat/transform.go @@ -20,6 +20,8 @@ import ( "strings" "time" + "gopkg.in/yaml.v3" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) @@ -110,6 +112,18 @@ func transformCSVToArray(value any) (any, error) { return result, nil } +// transformJSONParse parses a CLI string into a structured value so callers can +// pass complex payloads (JSON arrays/objects) through a single flag. +// +// Two input dialects are accepted, in order: +// 1. Strict JSON — `[{"fieldName":"x","type":"text"}]` +// 2. YAML (flow) — `[{fieldName: x, type: text}]` +// +// YAML is a superset of JSON that permits unquoted keys and strings, which +// dramatically reduces the need for shell-level escaping. Users can therefore +// write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes +// around every token. The output shape is the same either way; downstream +// consumers see the parsed Go value, not the original dialect. func transformJSONParse(value any) (any, error) { s, ok := toString(value) if !ok { @@ -119,11 +133,22 @@ func transformJSONParse(value any) (any, error) { if s == "" { return value, nil } + // Strict JSON first — fast path and unambiguous type promotion (numbers + // stay numbers, etc.). var parsed any - if err := json.Unmarshal([]byte(s), &parsed); err != nil { - return nil, apperrors.NewValidation(fmt.Sprintf("json_parse: invalid JSON: %v", err)) + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + return parsed, nil } - return parsed, nil + // YAML (flow) fallback — accepts `{key: value}` without surrounding + // quotes, which is the natural form when typing at a shell prompt. + if err := yaml.Unmarshal([]byte(s), &parsed); err == nil { + return parsed, nil + } + return nil, apperrors.NewValidation( + "json_parse: input is not valid JSON or YAML; " + + "quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " + + "or pass `@path/to/file.json` to read from a file", + ) } func transformEnumMap(value any, args map[string]any) (any, error) { diff --git a/internal/compat/transform_test.go b/internal/compat/transform_test.go new file mode 100644 index 00000000..c17f3ca0 --- /dev/null +++ b/internal/compat/transform_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "reflect" + "testing" +) + +// TestJSONParse_StrictJSON covers the primary path: callers passing +// canonical JSON (as generated programmatically or by agents). +func TestJSONParse_StrictJSON(t *testing.T) { + t.Parallel() + + input := `[{"fieldName":"title","type":"text"},{"fieldName":"count","type":"number"}]` + got, err := ApplyTransform(input, "json_parse", nil) + if err != nil { + t.Fatalf("strict JSON should parse, got err: %v", err) + } + arr, ok := got.([]any) + if !ok || len(arr) != 2 { + t.Fatalf("expected []any of length 2, got %T %v", got, got) + } +} + +// TestJSONParse_YAMLFlowFallback is the motivating case: a user types an +// ad-hoc JSON-shaped array without quoting every key and value. YAML flow +// syntax accepts it and the parsed output is indistinguishable from the +// strict-JSON equivalent. +func TestJSONParse_YAMLFlowFallback(t *testing.T) { + t.Parallel() + + // Intentionally unquoted keys, unquoted string values, and Chinese + // identifiers — typical of what humans type at a shell. + input := `[{fieldName: 标题, type: text}, {fieldName: 数量, type: number, config: {formatter: INT}}, {fieldName: 状态, type: singleSelect, config: {options: [{name: 待办}, {name: 进行中}, {name: 已完成}]}}, {fieldName: 已确认, type: checkbox}]` + + got, err := ApplyTransform(input, "json_parse", nil) + if err != nil { + t.Fatalf("YAML-flow input should parse, got err: %v", err) + } + arr, ok := got.([]any) + if !ok { + t.Fatalf("expected []any, got %T", got) + } + if len(arr) != 4 { + t.Fatalf("expected 4 field definitions, got %d", len(arr)) + } + + // Spot-check the third entry, which is the most deeply nested. + third, ok := arr[2].(map[string]any) + if !ok { + t.Fatalf("arr[2] expected map[string]any, got %T", arr[2]) + } + if third["fieldName"] != "状态" { + t.Errorf("arr[2].fieldName: want 状态, got %v", third["fieldName"]) + } + config, ok := third["config"].(map[string]any) + if !ok { + t.Fatalf("arr[2].config expected map, got %T", third["config"]) + } + options, ok := config["options"].([]any) + if !ok || len(options) != 3 { + t.Fatalf("arr[2].config.options: want 3 items, got %v", config["options"]) + } +} + +// TestJSONParse_EmptyString preserves the legacy behaviour of returning the +// original value untouched when the caller passes an empty / whitespace-only +// string, matching how other transforms treat empty input. +func TestJSONParse_EmptyString(t *testing.T) { + t.Parallel() + + cases := []string{"", " ", "\n\t"} + for _, in := range cases { + got, err := ApplyTransform(in, "json_parse", nil) + if err != nil { + t.Errorf("empty input %q should not error: %v", in, err) + continue + } + if !reflect.DeepEqual(got, in) { + t.Errorf("empty input %q should pass through, got %v", in, got) + } + } +} + +// TestJSONParse_NonString passes through non-string inputs (already-parsed +// values flowing through the pipeline). +func TestJSONParse_NonString(t *testing.T) { + t.Parallel() + + preParsed := []any{map[string]any{"k": "v"}} + got, err := ApplyTransform(preParsed, "json_parse", nil) + if err != nil { + t.Fatalf("non-string should pass through: %v", err) + } + if !reflect.DeepEqual(got, preParsed) { + t.Errorf("non-string should pass through unchanged, got %v", got) + } +} + +// TestJSONParse_InvalidInput verifies that genuine garbage is still rejected +// with a user-facing validation error that nudges towards `@file` syntax. +func TestJSONParse_InvalidInput(t *testing.T) { + t.Parallel() + + // Unterminated bracket — neither valid JSON nor valid YAML flow. + _, err := ApplyTransform("[{fieldName:", "json_parse", nil) + if err == nil { + t.Fatal("expected error for malformed input") + } + if msg := err.Error(); msg == "" { + t.Fatal("error message should be non-empty") + } +} diff --git a/internal/compat/v3_helpers_test.go b/internal/compat/v3_helpers_test.go new file mode 100644 index 00000000..e60b7bd2 --- /dev/null +++ b/internal/compat/v3_helpers_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +package compat + +import ( + "context" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map. +func TestKindFromTypeName(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want ValueKind + }{ + {"", ValueString}, + {"string", ValueString}, + {"STRING", ValueString}, + {"int", ValueInt}, + {"integer", ValueInt}, + {"number", ValueInt}, + {"bool", ValueBool}, + {"boolean", ValueBool}, + {"stringSlice", ValueStringSlice}, + {"string_slice", ValueStringSlice}, + {"[]string", ValueStringSlice}, + {"weird-unknown-type", ValueString}, + } + for _, tc := range cases { + if got := kindFromTypeName(tc.in); got != tc.want { + t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +// TestApplyOmitWhen verifies all three modes plus zero-detection. +func TestApplyOmitWhen(t *testing.T) { + t.Parallel() + + t.Run("mode_zero_drops_zero_values", func(t *testing.T) { + params := map[string]any{ + "a": 0, + "b": "", + "c": false, + "d": []any{}, + "e": map[string]any{}, + "f": "non-empty", + "g": 42, + } + for k := range params { + applyOmitWhen(params, k, "zero") + } + for _, k := range []string{"a", "b", "c", "d", "e"} { + if _, ok := params[k]; ok { + t.Errorf("expected key %q to be dropped by omitWhen=zero", k) + } + } + for _, k := range []string{"f", "g"} { + if _, ok := params[k]; !ok { + t.Errorf("expected key %q to be preserved", k) + } + } + }) + + t.Run("mode_never_preserves_zero_values", func(t *testing.T) { + params := map[string]any{"explicitFalse": false, "explicitZero": 0} + applyOmitWhen(params, "explicitFalse", "never") + applyOmitWhen(params, "explicitZero", "never") + if len(params) != 2 { + t.Errorf("expected both keys preserved under 'never', got %v", params) + } + }) + + t.Run("mode_empty_is_noop", func(t *testing.T) { + params := map[string]any{"a": ""} + applyOmitWhen(params, "a", "empty") + if _, ok := params["a"]; !ok { + t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)") + } + }) + + t.Run("missing_key_safe", func(t *testing.T) { + params := map[string]any{} + applyOmitWhen(params, "nope", "zero") + if len(params) != 0 { + t.Errorf("expected no-op on missing key, got %v", params) + } + }) +} + +// TestIsZeroValue covers every branch of the helper. +func TestIsZeroValue(t *testing.T) { + t.Parallel() + zeros := []any{ + nil, + "", + " ", + false, + 0, + int64(0), + float64(0), + []any{}, + []string{}, + map[string]any{}, + } + for i, z := range zeros { + if !isZeroValue(z) { + t.Errorf("case %d: expected zero value for %#v", i, z) + } + } + nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}} + for i, nz := range nonZeros { + if isZeroValue(nz) { + t.Errorf("case %d: expected non-zero for %#v", i, nz) + } + } +} + +// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve. +func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) { + // NOTE: not t.Parallel — edition.Get() global state is shared. + resolvers := runtimeDefaultResolvers() + now := resolvers["$now"] + if now == nil { + t.Fatal("$now resolver missing") + } + if v, ok := now(context.Background()); !ok || v == "" { + t.Errorf("$now returned empty value: %q ok=%v", v, ok) + } + today := resolvers["$today"] + if today == nil { + t.Fatal("$today resolver missing") + } + if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") { + t.Errorf("$today returned unexpected value: %q ok=%v", v, ok) + } +} + +// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook. +func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) { + prev := edition.Get() + defer edition.Override(prev) + + edition.Override(&edition.Hooks{ + RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn { + return map[string]edition.RuntimeDefaultFn{ + "$currentUserId": func(ctx context.Context) (string, bool) { + return "test-user-001", true + }, + } + }, + }) + + resolvers := runtimeDefaultResolvers() + fn := resolvers["$currentUserId"] + if fn == nil { + t.Fatal("$currentUserId missing after overlay install") + } + if v, ok := fn(context.Background()); !ok || v != "test-user-001" { + t.Errorf("$currentUserId=%q ok=%v", v, ok) + } +} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index d89360aa..621f75a7 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -95,7 +95,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command { invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), - "chat", + "bot", tool, params, ) diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index 76803431..9ff27be6 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -43,4 +43,55 @@ func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) { if got := runner.last.Params["robotCode"]; got != "robot-001" { t.Fatalf("robotCode = %#v, want robot-001", got) } + if got := runner.last.CanonicalProduct; got != "bot" { + t.Fatalf("CanonicalProduct = %q, want bot", got) + } +} + +func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) { + cases := []struct { + name string + args []string + wantTool string + }{ + { + name: "single-chat", + args: []string{ + "--users", "user-001", + "--robot-code", "robot-001", + "--title", "t", + "--text", "x", + }, + wantTool: "batch_send_robot_msg_to_users", + }, + { + name: "group-chat", + args: []string{ + "--group", "cid-xyz", + "--robot-code", "robot-001", + "--title", "t", + "--text", "x", + }, + wantTool: "send_robot_group_message", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runner := &captureRunner{} + cmd := newChatMessageSendByBotCommand(runner) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) + } + if got := runner.last.CanonicalProduct; got != "bot" { + t.Fatalf("CanonicalProduct = %q, want bot", got) + } + if got := runner.last.Tool; got != tc.wantTool { + t.Fatalf("Tool = %q, want %q", got, tc.wantTool) + } + }) + } } diff --git a/internal/ir/catalog.go b/internal/ir/catalog.go index b21850e4..1ee76716 100644 --- a/internal/ir/catalog.go +++ b/internal/ir/catalog.go @@ -49,6 +49,29 @@ type CLIFlagHint struct { Alias string `json:"alias,omitempty"` } +// FlagOverlay carries CLI-layer transformation metadata for a single +// MCP parameter: the flag alias the user types, the transform applied +// before dispatch, env-var fallback, default value, and whether the flag +// is hidden from help. Sourced from market.CLIToolOverride.Flags. +type FlagOverlay struct { + Alias string `json:"alias,omitempty"` + Transform string `json:"transform,omitempty"` + TransformArgs map[string]any `json:"transform_args,omitempty"` + EnvDefault string `json:"env_default,omitempty"` + Default string `json:"default,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +// ToolAnnotations mirrors MCP 2025+ tool annotations. All hints are +// nullable: absence means "unknown", not "false". Populate only when the +// source has a clear signal — don't guess. +type ToolAnnotations struct { + DestructiveHint *bool `json:"destructive_hint,omitempty"` + ReadOnlyHint *bool `json:"read_only_hint,omitempty"` + IdempotentHint *bool `json:"idempotent_hint,omitempty"` + OpenWorldHint *bool `json:"open_world_hint,omitempty"` +} + type CanonicalProduct struct { ID string `json:"id"` DisplayName string `json:"display_name"` @@ -67,13 +90,16 @@ type CanonicalProduct struct { type ToolDescriptor struct { RPCName string `json:"rpc_name"` CLIName string `json:"cli_name,omitempty"` + Group string `json:"group,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` InputSchema map[string]any `json:"input_schema,omitempty"` OutputSchema map[string]any `json:"output_schema,omitempty"` Sensitive bool `json:"sensitive"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` Hidden bool `json:"hidden,omitempty"` FlagHints map[string]CLIFlagHint `json:"flag_hints,omitempty"` + FlagOverlay map[string]FlagOverlay `json:"flag_overlay,omitempty"` SourceServerKey string `json:"source_server_key"` CanonicalPath string `json:"canonical_path"` } @@ -105,10 +131,58 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { sensitiveByTool := make(map[string]bool, len(runtimeServer.Server.CLI.Tools)) hasSensitiveOverride := make(map[string]bool, len(runtimeServer.Server.CLI.Tools)) toolCLIName := make(map[string]string, len(runtimeServer.Server.CLI.Tools)) + toolGroup := make(map[string]string) toolHidden := make(map[string]bool, len(runtimeServer.Server.CLI.Tools)) toolTitleOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools)) toolDescriptionOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools)) toolFlagHints := make(map[string]map[string]CLIFlagHint, len(runtimeServer.Server.CLI.Tools)) + toolFlagOverlay := make(map[string]map[string]FlagOverlay) + + // Merge richer market.CLIToolOverride data (group, cliName, flag + // transforms/aliases/env-defaults) that the dynamic compat layer + // already consumes but the IR previously dropped. + for name, override := range runtimeServer.Server.CLI.ToolOverrides { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if cn := strings.TrimSpace(override.CLIName); cn != "" { + toolCLIName[trimmed] = cn + } + if gp := strings.TrimSpace(override.Group); gp != "" { + toolGroup[trimmed] = gp + } + if override.Hidden { + toolHidden[trimmed] = true + } + if override.IsSensitive { + sensitiveByTool[trimmed] = true + hasSensitiveOverride[trimmed] = true + } + if desc := strings.TrimSpace(override.Description); desc != "" && toolDescriptionOverride[trimmed] == "" { + toolDescriptionOverride[trimmed] = desc + } + if len(override.Flags) > 0 { + overlay := make(map[string]FlagOverlay, len(override.Flags)) + for paramName, flagOverride := range override.Flags { + paramTrim := strings.TrimSpace(paramName) + if paramTrim == "" { + continue + } + overlay[paramTrim] = FlagOverlay{ + Alias: strings.TrimSpace(flagOverride.Alias), + Transform: strings.TrimSpace(flagOverride.Transform), + TransformArgs: cloneTransformArgs(flagOverride.TransformArgs), + EnvDefault: strings.TrimSpace(flagOverride.EnvDefault), + Default: strings.TrimSpace(flagOverride.Default), + Hidden: flagOverride.Hidden, + } + } + if len(overlay) > 0 { + toolFlagOverlay[trimmed] = overlay + } + } + } for _, cliTool := range runtimeServer.Server.CLI.Tools { name := strings.TrimSpace(cliTool.Name) if name == "" { @@ -164,13 +238,16 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog { tools = append(tools, ToolDescriptor{ RPCName: tool.Name, CLIName: cliName, + Group: strings.TrimSpace(toolGroup[tool.Name]), Title: title, Description: description, InputSchema: cloneMap(tool.InputSchema), OutputSchema: cloneMap(tool.OutputSchema), Sensitive: sensitive, + Annotations: deriveAnnotations(sensitive), Hidden: toolHidden[tool.Name], FlagHints: cloneFlagHints(toolFlagHints[tool.Name]), + FlagOverlay: cloneFlagOverlay(toolFlagOverlay[tool.Name]), SourceServerKey: runtimeServer.Server.Key, CanonicalPath: fmt.Sprintf("%s.%s", productID, tool.Name), }) @@ -363,3 +440,43 @@ func cloneFlagHints(value map[string]CLIFlagHint) map[string]CLIFlagHint { } return out } + +func cloneFlagOverlay(value map[string]FlagOverlay) map[string]FlagOverlay { + if len(value) == 0 { + return nil + } + out := make(map[string]FlagOverlay, len(value)) + for key, overlay := range value { + overlay.TransformArgs = cloneTransformArgs(overlay.TransformArgs) + out[key] = overlay + } + return out +} + +func cloneTransformArgs(value map[string]any) map[string]any { + if len(value) == 0 { + return nil + } + data, err := json.Marshal(value) + if err != nil { + return nil + } + var cloned map[string]any + if err := json.Unmarshal(data, &cloned); err != nil { + return nil + } + return cloned +} + +// deriveAnnotations maps the coarse-grained Sensitive bool onto MCP 2025+ +// annotation hints. Only DestructiveHint is populated — the other hints +// (read_only, idempotent, open_world) stay nil until the upstream tool +// manifest advertises them explicitly. Guessing from name prefixes +// ("list_", "get_") risks false signals for AI agents. +func deriveAnnotations(sensitive bool) *ToolAnnotations { + if !sensitive { + return nil + } + destructive := true + return &ToolAnnotations{DestructiveHint: &destructive} +} diff --git a/internal/ir/catalog_test.go b/internal/ir/catalog_test.go index cee2d843..6cca9f4c 100644 --- a/internal/ir/catalog_test.go +++ b/internal/ir/catalog_test.go @@ -216,6 +216,114 @@ func TestBuildCatalogFallsBackToRuntimeSensitiveMetadata(t *testing.T) { } } +func TestBuildCatalogCarriesToolOverrideGroupAndFlagOverlay(t *testing.T) { + t.Parallel() + + catalog := BuildCatalog([]discovery.RuntimeServer{ + { + Server: market.ServerDescriptor{ + Key: "ding-key", + DisplayName: "DING", + Endpoint: "https://example.com/server/ding", + CLI: market.CLIOverlay{ + Command: "ding", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_ding_message": { + CLIName: "send", + Group: "message", + IsSensitive: true, + Flags: map[string]market.CLIFlagOverride{ + "receiverUserIdList": { + Alias: "users", + Transform: "csv_to_array", + }, + "robotCode": { + Alias: "robot-code", + EnvDefault: "DINGTALK_DING_ROBOT_CODE", + }, + "remindType": { + Alias: "type", + Transform: "enum_map", + TransformArgs: map[string]any{ + "_default": float64(1), + "app": float64(1), + }, + }, + }, + }, + }, + }, + }, + Tools: []transport.ToolDescriptor{ + {Name: "send_ding_message", Title: "发送DING消息", InputSchema: map[string]any{"type": "object"}}, + }, + }, + }) + + if len(catalog.Products) != 1 { + t.Fatalf("products len = %d, want 1", len(catalog.Products)) + } + product := catalog.Products[0] + if product.ID != "ding" { + t.Fatalf("product ID = %q, want ding", product.ID) + } + tool, ok := product.FindTool("send_ding_message") + if !ok { + t.Fatalf("tool not found") + } + if tool.CLIName != "send" { + t.Fatalf("CLIName = %q, want send", tool.CLIName) + } + if tool.Group != "message" { + t.Fatalf("Group = %q, want message", tool.Group) + } + if !tool.Sensitive { + t.Fatalf("Sensitive = false, want true (from overlay)") + } + if tool.Annotations == nil || tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint { + t.Fatalf("DestructiveHint not propagated, got %#v", tool.Annotations) + } + if tool.Annotations.ReadOnlyHint != nil { + t.Fatalf("ReadOnlyHint should be nil when source is unknown, got %#v", *tool.Annotations.ReadOnlyHint) + } + users := tool.FlagOverlay["receiverUserIdList"] + if users.Alias != "users" || users.Transform != "csv_to_array" { + t.Fatalf("receiverUserIdList overlay = %#v", users) + } + robot := tool.FlagOverlay["robotCode"] + if robot.Alias != "robot-code" || robot.EnvDefault != "DINGTALK_DING_ROBOT_CODE" { + t.Fatalf("robotCode overlay = %#v", robot) + } + remind := tool.FlagOverlay["remindType"] + if remind.Transform != "enum_map" { + t.Fatalf("remindType.Transform = %q, want enum_map", remind.Transform) + } + if v, _ := remind.TransformArgs["app"].(float64); v != 1 { + t.Fatalf("remindType.TransformArgs[app] = %v, want 1", remind.TransformArgs["app"]) + } +} + +func TestBuildCatalogLeavesAnnotationsNilForNonSensitive(t *testing.T) { + t.Parallel() + + catalog := BuildCatalog([]discovery.RuntimeServer{ + { + Server: market.ServerDescriptor{ + Key: "doc-key", + DisplayName: "文档", + Endpoint: "https://example.com/server/doc", + }, + Tools: []transport.ToolDescriptor{ + {Name: "list_documents", InputSchema: map[string]any{"type": "object"}}, + }, + }, + }) + tool := catalog.Products[0].Tools[0] + if tool.Annotations != nil { + t.Fatalf("Annotations = %#v, want nil for non-sensitive tool", tool.Annotations) + } +} + func TestBuildCatalogConsumesCLIRouteMetadata(t *testing.T) { t.Parallel() diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 9e0fc9b3..1519c3ac 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -23,6 +23,13 @@ const ( // AccountToken is the account key for storing auth token data. AccountToken = "auth-token" + + // StorageDirEnv overrides the on-disk keychain storage root on + // platforms that use file-backed storage (macOS, Linux). It is + // intended for tests that need to isolate keychain state from the + // real user environment and from sibling test packages running in + // parallel. When empty, the platform default applies. + StorageDirEnv = "DWS_KEYCHAIN_DIR" ) // KeychainAccess abstracts keychain Get/Set/Remove for dependency injection. diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 3c2bbad6..76bf7c38 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -40,7 +40,12 @@ const ( // StorageDir returns the storage directory for a given service name on macOS. // Uses ~/Library/Application Support/ following Apple conventions. +// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for +// isolation), the storage root is taken from that env var instead. func StorageDir(service string) string { + if override := os.Getenv(StorageDirEnv); override != "" { + return filepath.Join(override, service) + } home, err := os.UserHomeDir() if err != nil || home == "" { return filepath.Join(".dws", "keychain", service) diff --git a/internal/keychain/keychain_linux.go b/internal/keychain/keychain_linux.go index f7de6c4b..78db000e 100644 --- a/internal/keychain/keychain_linux.go +++ b/internal/keychain/keychain_linux.go @@ -34,8 +34,13 @@ const ( ) // StorageDir returns the storage directory for a given service name. -// Follows XDG Base Directory Specification: ~/.local/share/ +// Follows XDG Base Directory Specification: ~/.local/share/. +// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for +// isolation), the storage root is taken from that env var instead. func StorageDir(service string) string { + if override := os.Getenv(StorageDirEnv); override != "" { + return filepath.Join(override, service) + } home, err := os.UserHomeDir() if err != nil || home == "" { fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) diff --git a/internal/market/registry.go b/internal/market/registry.go index 4963c179..59db170d 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -38,6 +38,7 @@ const ( type Client struct { BaseURL string HTTPClient *http.Client + Headers map[string]string } type ListResponse struct { @@ -48,6 +49,26 @@ type ListResponse struct { type ListMetadata struct { Count int `json:"count"` NextCursor string `json:"nextCursor"` + // Warnings is populated by the Portal merge service when envelopes are + // dropped (e.g. missing serverDeps, status != active) or when a dangling + // serverDeps / toolOverrides.*.serverOverride reference is detected. + // Old Portals without the field simply leave this nil. CLI side should + // treat any non-empty slice as non-fatal informational output: print to + // stderr so cache refreshes expose Portal drift to the user, but do not + // fail the discovery load. + // + // See plan fix-wukong-discovery-missing-servers Phase 4.2/4.3. + Warnings []ListWarning `json:"warnings,omitempty"` +} + +// ListWarning describes one envelope that was filtered out of the merged +// response or flagged for dangling references. Fields mirror the JSON emitted +// by WukongDiscoveryRegistry.buildWarning on the Portal side; unknown reason +// codes are passed through verbatim for forward compatibility. +type ListWarning struct { + ProductID string `json:"productId"` + Reason string `json:"reason"` + Detail string `json:"detail"` } type ServerEnvelope struct { @@ -97,6 +118,37 @@ type CLIOverlay struct { Tools []CLITool `json:"tools"` Groups map[string]CLIGroupDef `json:"groups,omitempty"` ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"` + // ServerDeps declares other product IDs that this overlay depends on at + // runtime (e.g. chat depends on bot for cross-server tool routing). + // Consumed by the portal merge service for fail-fast validation; CLI side + // currently only stores the value for tooling/introspection. + ServerDeps []string `json:"serverDeps,omitempty"` + // Hints registers stub sub-commands under the overlay root that only + // print a redirect message pointing to the canonical command path. Used + // for deprecated command aliases and "did-you-mean" style hints. + // Key is the sub-command name; value describes the target path. + Hints map[string]CLIHintDef `json:"hintCommands,omitempty"` + // RedirectTo, when non-empty, turns the entire top-level product into a + // stub that prints "Please use: dws " and performs no work. Used + // for deprecated top-level products migrated to new paths (e.g. + // `bot → chat bot`, `message → chat message`). See schema v3 §2.6. + RedirectTo string `json:"redirectTo,omitempty"` +} + +// CLIHintDef declares a stub sub-command that prints a redirect message. +// The command takes no bindings and calls no tool; its sole purpose is to +// help users migrate from an old command path to the new one. +type CLIHintDef struct { + // Target is the canonical command path shown in the redirect message + // (e.g. "dws chat message list"). + Target string `json:"target"` + // Description overrides the Short/Long help text for the hint command. + // Empty falls back to a generic "use: " string. + Description string `json:"description,omitempty"` + // Group optionally nests the hint under a named sub-group (same syntax + // as CLIToolOverride.Group with dot-separated paths). Empty means the + // hint is attached directly to the overlay root. + Group string `json:"group,omitempty"` } // CLIGroupDef defines a sub-command group within a CLI module. @@ -104,25 +156,111 @@ type CLIGroupDef struct { Description string `json:"description"` } +// CLIOutputFormat declares structured post-processing applied to the MCP tool +// response before the formatter prints it. See schema v3 §2.5. +// +// Apply order: Drop → Rename → Columns. All three are optional. +type CLIOutputFormat struct { + // Rename moves fields from src key to dst key at top level and one level + // of nested objects. Missing src keys are silently ignored. + Rename map[string]string `json:"rename,omitempty"` + // Drop removes these keys from the response (top level + one level deep). + Drop []string `json:"drop,omitempty"` + // Columns controls column order and subset for --format=table. Ignored in + // JSON output mode. + Columns []string `json:"columns,omitempty"` +} + // CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms. type CLIToolOverride struct { - CLIName string `json:"cliName"` - Description string `json:"description,omitempty"` - Group string `json:"group,omitempty"` - IsSensitive bool `json:"isSensitive,omitempty"` - Hidden bool `json:"hidden,omitempty"` - Flags map[string]CLIFlagOverride `json:"flags,omitempty"` - OutputFormat map[string]any `json:"outputFormat,omitempty"` + CLIName string `json:"cliName"` + Description string `json:"description,omitempty"` + // Example, when non-empty, is wired to cobra.Command.Example to render + // the "Examples:" section in --help. Mirrors hardcoded helper commands' + // Example field (e.g. wukong/products/oa.go list-forms). Empty value + // produces no Examples section. Multi-line strings keep "\n" literally. + Example string `json:"example,omitempty"` + Group string `json:"group,omitempty"` + IsSensitive bool `json:"isSensitive,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Flags map[string]CLIFlagOverride `json:"flags,omitempty"` + // OutputFormat declares structured response post-processing. v3 typed form + // supersedes v2's untyped map[string]any, but parsing stays lenient so v2 + // envelopes continue to deserialize (unknown keys are ignored). + OutputFormat CLIOutputFormat `json:"outputFormat,omitempty"` + // ServerOverride routes this leaf command's tool invocation to a different + // product's MCP server (e.g. `chat bot ...` leaves live under the `chat` + // command tree but call the `bot` endpoint). Empty means use the enclosing + // overlay's server. + ServerOverride string `json:"serverOverride,omitempty"` + // BodyWrapper, when non-empty, wraps the collected params map under this + // single key before the invocation is dispatched. Useful when the upstream + // tool expects a typed DTO wrapper (e.g. `PersonalTodoCreateVO`). Only + // user-provided params are wrapped; internal control keys starting with + // '_' (e.g. `_blocked`, `_yes`) are preserved at the top level. + BodyWrapper string `json:"bodyWrapper,omitempty"` + // MutuallyExclusive groups flag aliases that must not be set together. + // Each inner slice becomes one cobra.MarkFlagsMutuallyExclusive call. + // Example: [["group","user","open-dingtalk-id"]] for `chat message list`. + MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"` + // RequireOneOf groups flag aliases where at least one must be set. Each + // inner slice becomes one cobra.MarkFlagsOneRequired call. Typically + // paired with MutuallyExclusive to enforce "exactly one of". + RequireOneOf [][]string `json:"requireOneOf,omitempty"` + // RedirectTo, when non-empty, turns this entry into a stub command that + // prints the redirect target instead of invoking a tool. All other + // fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are + // ignored. Use for deprecated leaf commands that moved to a new path. + RedirectTo string `json:"redirectTo,omitempty"` } // CLIFlagOverride describes how to map an MCP parameter to a CLI flag. type CLIFlagOverride struct { - Alias string `json:"alias"` + Alias string `json:"alias"` + // Aliases registers additional hidden CLI flag names for the same MCP + // parameter. Use to preserve legacy flag names when migrating from + // hardcoded commands (mirrors cmdutil.ValidateRequiredFlagWithAliases / + // cmdutil.FlagOrFallback). All entries are registered as hidden flags + // (not shown in --help); values are deduped against the primary flag + // name and Alias, and reserved names ("json", "params") are skipped. + // When any alias is set by the user, the binding's Required check is + // satisfied and the value is written to params[Property]. + Aliases []string `json:"aliases,omitempty"` Transform string `json:"transform,omitempty"` TransformArgs map[string]any `json:"transformArgs,omitempty"` EnvDefault string `json:"envDefault,omitempty"` Hidden bool `json:"hidden,omitempty"` Default string `json:"default,omitempty"` + // Shorthand registers a single-char flag alias (cobra StringP etc.). + Shorthand string `json:"shorthand,omitempty"` + // Required marks this flag as mandatory via cobra.MarkFlagRequired. + // Ignored when Positional is true (positional args have their own arity rules). + Required bool `json:"required,omitempty"` + // Description overrides the usage string displayed in --help; takes + // priority over the Detail API's toolDesc when non-empty. + Description string `json:"description,omitempty"` + // Positional, when true, binds this parameter to a positional CLI argument + // instead of a --flag. PositionalIndex (0-based) selects which arg slot. + Positional bool `json:"positional,omitempty"` + PositionalIndex int `json:"positionalIndex,omitempty"` + // Type explicitly declares the flag's type: "string" (default) / "int" / + // "bool" / "stringSlice". When set, overrides the type inferred from MCP + // tools/list inputSchema. See schema v3 §2.1. + Type string `json:"type,omitempty"` + // OmitWhen declares empty-value handling when building the invocation body: + // + // "empty" (default): empty string / zero-length slice-or-map → omit + // "zero": + zero numbers / false booleans → omit + // "never": always send, even at zero value (explicit-zero semantics) + // + // See schema v3 §2.2. + OmitWhen string `json:"omitWhen,omitempty"` + // RuntimeDefault, when non-empty, injects a runtime-resolved value if the + // user omits the flag. Allowed placeholders: "$currentUserId" / "$unionId" + // / "$corpId" / "$now" / "$today". Unknown placeholders → warning + skip. + // Resolution comes from edition.Hooks.RuntimeDefaults; open-source core + // only recognises the placeholder set. See schema v3 §2.3. + RuntimeDefault string `json:"runtimeDefault,omitempty"` } type CLITool struct { @@ -256,6 +394,9 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR if err != nil { return ListResponse{}, apperrors.NewDiscovery("failed to create servers request") } + for k, v := range c.Headers { + req.Header.Set(k, v) + } resp, err := c.HTTPClient.Do(req) if err != nil { return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("servers request failed: %v", err)) @@ -289,6 +430,9 @@ func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string) if err != nil { return ListResponse{}, apperrors.NewDiscovery("failed to create market servers request") } + for k, v := range c.Headers { + req.Header.Set(k, v) + } resp, err := c.HTTPClient.Do(req) if err != nil { return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("market servers request failed: %v", err)) diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dbed8105..a7979eba 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -29,9 +29,10 @@ import ( type Format string const ( - FormatJSON Format = "json" - FormatTable Format = "table" - FormatRaw Format = "raw" + FormatJSON Format = "json" + FormatTable Format = "table" + FormatRaw Format = "raw" + FormatPretty Format = "pretty" ) var preferredListKeys = []string{"items", "results", "data", "list", "records", "tools", "servers", "products"} @@ -75,6 +76,8 @@ func Write(w io.Writer, format Format, payload any) error { return writeRaw(w, payload) case FormatTable: return writeTableish(w, payload) + case FormatPretty: + return writePretty(w, payload) default: return WriteJSON(w, payload) } @@ -123,6 +126,8 @@ func normalizeFormat(raw string, fallback Format) Format { return FormatRaw case string(FormatTable): return FormatTable + case string(FormatPretty): + return FormatPretty default: return fallback } diff --git a/internal/output/pretty.go b/internal/output/pretty.go new file mode 100644 index 00000000..e9f02b70 --- /dev/null +++ b/internal/output/pretty.go @@ -0,0 +1,302 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package output + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/fatih/color" +) + +// writePretty renders the payload as ANSI-colored, human-readable text. +// If the payload looks like a `dws schema` response (has `kind: "schema"`), +// a schema-specific hierarchical renderer runs. Anything else falls back +// to the table renderer so `--format pretty` is safe on any command. +func writePretty(w io.Writer, payload any) error { + normalized, err := normalizePayload(payload) + if err != nil { + return err + } + + if m, ok := normalized.(map[string]any); ok { + if kind, _ := m["kind"].(string); kind == "schema" { + return writeSchemaPretty(w, m) + } + } + return writeTableish(w, normalized) +} + +// writeSchemaPretty handles both the list-all shape (has `products`) and +// the single-tool shape (has `tool`). Colours are on by default via fatih/color, +// auto-disabled when stdout is not a TTY or when NO_COLOR is set. +func writeSchemaPretty(w io.Writer, payload map[string]any) error { + bold := color.New(color.Bold).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + red := color.New(color.FgRed, color.Bold).SprintFunc() + dim := color.New(color.Faint).SprintFunc() + + if tool, ok := payload["tool"].(map[string]any); ok { + return writeSchemaToolPretty(w, payload, tool, bold, cyan, green, yellow, red, dim) + } + if products, ok := payload["products"].([]any); ok { + return writeSchemaListPretty(w, payload, products, bold, cyan, dim) + } + + if degraded, _ := payload["degraded"].(bool); degraded { + reason, _ := payload["reason"].(string) + hint, _ := payload["hint"].(string) + fmt.Fprintf(w, "%s discovery degraded: %s\n", red("!"), yellow(reason)) + if hint != "" { + fmt.Fprintf(w, " %s %s\n", dim("hint:"), hint) + } + return nil + } + return writeTableish(w, payload) +} + +func writeSchemaListPretty( + w io.Writer, + payload map[string]any, + products []any, + bold, cyan, dim func(...any) string, +) error { + count, _ := payload["count"].(float64) + if count == 0 { + count = float64(len(products)) + } + fmt.Fprintf(w, "%s %s products discovered\n", bold("Catalog"), cyan(fmt.Sprintf("%d", int(count)))) + + for _, raw := range products { + p, ok := raw.(map[string]any) + if !ok { + continue + } + id, _ := p["id"].(string) + name, _ := p["name"].(string) + desc, _ := p["description"].(string) + tools, _ := p["tools"].([]any) + fmt.Fprintf(w, "\n%s %s %s\n", bold("▸"), bold(id), dim(name)) + if desc != "" && desc != name { + fmt.Fprintf(w, " %s\n", dim(desc)) + } + fmt.Fprintf(w, " %s %s\n", dim("tools:"), cyan(fmt.Sprintf("%d", len(tools)))) + shown := 0 + for _, t := range tools { + tm, ok := t.(map[string]any) + if !ok { + continue + } + rpc, _ := tm["name"].(string) + cli, _ := tm["cli_name"].(string) + if cli == "" || cli == rpc { + fmt.Fprintf(w, " - %s\n", rpc) + } else { + fmt.Fprintf(w, " - %s %s\n", rpc, dim("→ "+cli)) + } + shown++ + if shown >= 6 && len(tools) > 8 { + fmt.Fprintf(w, " %s\n", dim(fmt.Sprintf("… %d more", len(tools)-shown))) + break + } + } + } + return nil +} + +func writeSchemaToolPretty( + w io.Writer, + payload map[string]any, + tool map[string]any, + bold, cyan, green, yellow, red, dim func(...any) string, +) error { + rpc, _ := tool["name"].(string) + cli, _ := tool["cli_name"].(string) + title, _ := tool["title"].(string) + desc, _ := tool["description"].(string) + group, _ := tool["group"].(string) + canonical, _ := tool["canonical_path"].(string) + + header := rpc + if title != "" && title != rpc { + header = fmt.Sprintf("%s %s", rpc, dim(title)) + } + fmt.Fprintf(w, "%s %s\n", bold("Tool"), header) + + var productID string + if product, ok := payload["product"].(map[string]any); ok { + pid, _ := product["id"].(string) + pname, _ := product["name"].(string) + productID = pid + fmt.Fprintf(w, " %s %s %s\n", dim("product:"), pid, dim(pname)) + } + if canonical != "" { + fmt.Fprintf(w, " %s %s\n", dim("canonical:"), canonical) + } + if cli != "" { + parts := []string{} + if productID != "" { + parts = append(parts, productID) + } + if group != "" { + parts = append(parts, strings.Split(group, ".")...) + } + parts = append(parts, cli) + fmt.Fprintf(w, " %s %s\n", dim("cli path:"), cyan(strings.Join(parts, " "))) + } + + // Sensitivity + annotations in one line. + sensitive, _ := tool["sensitive"].(bool) + if sensitive { + fmt.Fprintf(w, " %s %s\n", dim("sensitive:"), red("yes (needs --yes)")) + } + if ann, ok := tool["annotations"].(map[string]any); ok && len(ann) > 0 { + var parts []string + for _, key := range sortedMapKeys(ann) { + parts = append(parts, fmt.Sprintf("%s=%v", key, ann[key])) + } + fmt.Fprintf(w, " %s %s\n", dim("annotations:"), yellow(strings.Join(parts, " "))) + } + + if desc != "" && desc != title { + fmt.Fprintln(w) + for _, line := range strings.Split(strings.TrimSpace(desc), "\n") { + fmt.Fprintf(w, " %s\n", dim(line)) + } + } + + // Parameters section. + if params, ok := tool["parameters"].(map[string]any); ok && len(params) > 0 { + required := map[string]bool{} + if req, ok := tool["required"].([]any); ok { + for _, r := range req { + if s, ok := r.(string); ok { + required[s] = true + } + } + } + overlay := map[string]map[string]any{} + if ov, ok := tool["flag_overlay"].(map[string]any); ok { + for name, v := range ov { + if m, ok := v.(map[string]any); ok { + overlay[name] = m + } + } + } + fmt.Fprintln(w) + fmt.Fprintf(w, "%s\n", bold("Parameters")) + for _, name := range sortedMapKeys(params) { + prop, _ := params[name].(map[string]any) + writeParamPretty(w, name, prop, required[name], overlay[name], + bold, cyan, green, yellow, red, dim) + } + } + + // Output schema, if any. + if out, ok := tool["output_schema"].(map[string]any); ok && len(out) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, "%s\n", bold("Output schema")) + b, _ := json.MarshalIndent(out, " ", " ") + fmt.Fprintf(w, " %s\n", string(b)) + } + + return nil +} + +func writeParamPretty( + w io.Writer, + name string, + prop map[string]any, + required bool, + overlay map[string]any, + bold, cyan, green, yellow, red, dim func(...any) string, +) { + typeStr := describeType(prop) + marker := " " + if required { + marker = red("*") + } + + alias, _ := overlay["alias"].(string) + line := fmt.Sprintf(" %s %s", marker, bold(name)) + if alias != "" && alias != name { + line += fmt.Sprintf(" %s", cyan("--"+alias)) + } + line += fmt.Sprintf(" %s", dim(typeStr)) + fmt.Fprintln(w, line) + + if d, _ := prop["description"].(string); d != "" { + for _, ln := range strings.Split(strings.TrimSpace(d), "\n") { + fmt.Fprintf(w, " %s\n", dim(ln)) + } + } + + // enum values — render inline. + if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 { + vals := make([]string, 0, len(enum)) + for _, v := range enum { + vals = append(vals, fmt.Sprintf("%v", v)) + } + fmt.Fprintf(w, " %s %s\n", dim("enum:"), green(strings.Join(vals, ", "))) + } + + // overlay — transform / env default / default. + if transform, _ := overlay["transform"].(string); transform != "" { + extras := transform + if args, ok := overlay["transform_args"].(map[string]any); ok && len(args) > 0 { + kvs := make([]string, 0, len(args)) + for _, k := range sortedMapKeys(args) { + kvs = append(kvs, fmt.Sprintf("%s=%v", k, args[k])) + } + extras += "(" + strings.Join(kvs, ", ") + ")" + } + fmt.Fprintf(w, " %s %s\n", dim("transform:"), yellow(extras)) + } + if env, _ := overlay["env_default"].(string); env != "" { + fmt.Fprintf(w, " %s %s\n", dim("env default:"), green("$"+env)) + } + if def, _ := overlay["default"].(string); def != "" { + fmt.Fprintf(w, " %s %s\n", dim("default:"), green(def)) + } +} + +func describeType(prop map[string]any) string { + t, _ := prop["type"].(string) + if t == "array" { + if items, ok := prop["items"].(map[string]any); ok { + if inner, _ := items["type"].(string); inner != "" { + return inner + "[]" + } + } + return "array" + } + if t == "" { + return "any" + } + return t +} + +func sortedMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/output/pretty_test.go b/internal/output/pretty_test.go new file mode 100644 index 00000000..d2479203 --- /dev/null +++ b/internal/output/pretty_test.go @@ -0,0 +1,221 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/fatih/color" +) + +// forceNoColor disables ANSI codes for assertion simplicity. fatih/color +// would already do this when writing to a non-TTY, but tests use bytes.Buffer +// so we flip the flag explicitly. +func forceNoColor(t *testing.T) { + t.Helper() + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func TestPretty_SchemaListRendersProducts(t *testing.T) { + forceNoColor(t) + + payload := map[string]any{ + "kind": "schema", + "count": 2, + "products": []any{ + map[string]any{ + "id": "ding", + "name": "DING消息", + "description": "DING 消息 / 发送 / 撤回", + "tools": []any{ + map[string]any{"name": "send_ding_message", "cli_name": "send"}, + map[string]any{"name": "recall_ding_message", "cli_name": "recall"}, + }, + }, + map[string]any{ + "id": "doc", + "name": "钉钉文档", + "tools": []any{map[string]any{"name": "create_document", "cli_name": "create"}}, + }, + }, + } + + var buf bytes.Buffer + if err := writePretty(&buf, payload); err != nil { + t.Fatalf("writePretty() error = %v", err) + } + out := buf.String() + + wants := []string{ + "Catalog", + "2 products discovered", + "ding", + "send_ding_message", + "→ send", + "recall_ding_message", + "doc", + "create_document", + } + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("pretty list missing %q; got:\n%s", want, out) + } + } +} + +func TestPretty_SchemaToolRendersAllSections(t *testing.T) { + forceNoColor(t) + + payload := map[string]any{ + "kind": "schema", + "path": "ding.send_ding_message", + "product": map[string]any{ + "id": "ding", + "name": "DING消息", + }, + "tool": map[string]any{ + "name": "send_ding_message", + "cli_name": "send", + "canonical_path": "ding.send_ding_message", + "group": "message", + "title": "发送DING消息", + "description": "使用企业内机器人发送DING消息", + "sensitive": true, + "annotations": map[string]any{ + "destructive_hint": true, + }, + "parameters": map[string]any{ + "robotCode": map[string]any{ + "type": "string", + "description": "机器人Code", + }, + "receiverUserIdList": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "接收者用户ID列表", + }, + "remindType": map[string]any{ + "type": "number", + "description": "提醒类型", + }, + }, + "required": []any{"robotCode", "receiverUserIdList", "remindType"}, + "flag_overlay": map[string]any{ + "receiverUserIdList": map[string]any{ + "alias": "users", + "transform": "csv_to_array", + }, + "robotCode": map[string]any{ + "alias": "robot-code", + "env_default": "DINGTALK_DING_ROBOT_CODE", + }, + "remindType": map[string]any{ + "alias": "type", + "transform": "enum_map", + "transform_args": map[string]any{ + "app": 1, + "sms": 2, + "call": 3, + "_default": 1, + }, + }, + }, + }, + } + + var buf bytes.Buffer + if err := writePretty(&buf, payload); err != nil { + t.Fatalf("writePretty() error = %v", err) + } + out := buf.String() + + wants := []string{ + "Tool send_ding_message", // header + "canonical:", // meta section + "ding.send_ding_message", + "cli path:", + "ding message send", + "sensitive:", + "yes (needs --yes)", + "annotations:", + "destructive_hint=true", + "Parameters", + "robotCode", + "--robot-code", // overlay alias rendered + "env default:", + "$DINGTALK_DING_ROBOT_CODE", + "receiverUserIdList", + "--users", + "transform:", + "csv_to_array", + "string[]", // array shorthand + "remindType", + "--type", + "enum_map(", // with args inlined + "app=1", + } + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("pretty tool missing %q; full output:\n%s", want, out) + } + } + + // required params must show the red asterisk marker (even with color disabled + // the literal '*' still appears). + for _, req := range []string{"robotCode", "receiverUserIdList", "remindType"} { + // format: " * " + if !strings.Contains(out, "* "+req) { + t.Errorf("required marker missing for %q", req) + } + } +} + +func TestPretty_NonSchemaFallsBackToTableish(t *testing.T) { + forceNoColor(t) + + // Payload that doesn't have kind="schema" should fall through to + // the tableish renderer, not error or render a schema header. + payload := map[string]any{"items": []any{ + map[string]any{"id": "a", "name": "Alice"}, + map[string]any{"id": "b", "name": "Bob"}, + }} + + var buf bytes.Buffer + if err := writePretty(&buf, payload); err != nil { + t.Fatalf("writePretty() error = %v", err) + } + out := buf.String() + if strings.Contains(out, "Catalog") || strings.Contains(out, "Tool ") { + t.Errorf("pretty wrongly ran schema renderer on non-schema payload:\n%s", out) + } + // tableish should produce header + rows + for _, want := range []string{"id", "name", "Alice", "Bob"} { + if !strings.Contains(out, want) { + t.Errorf("tableish fallback missing %q:\n%s", want, out) + } + } +} + +func TestNormalizeFormat_RecognisesPretty(t *testing.T) { + if got := normalizeFormat("pretty", FormatJSON); got != FormatPretty { + t.Errorf("normalizeFormat(pretty) = %q, want %q", got, FormatPretty) + } + if got := normalizeFormat("PRETTY", FormatJSON); got != FormatPretty { + t.Errorf("normalizeFormat(PRETTY) = %q, want %q", got, FormatPretty) + } +} diff --git a/pkg/authretry/authretry.go b/pkg/authretry/authretry.go new file mode 100644 index 00000000..eedf7fe1 --- /dev/null +++ b/pkg/authretry/authretry.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package authretry exposes the AuthRefreshRequired marker error used by +// edition overlays (in separate Go modules) to signal "the current access +// token was rejected by the server; please run a force-refresh and retry". +// +// The package has no dependencies beyond the standard library so it can be +// imported by both the runner (internal/app) and external overlays without +// creating import cycles with packages such as pkg/runtimetoken. +package authretry + +import ( + stderrors "errors" +) + +// AuthRefreshRequired is a marker error returned by overlays (typically from +// the edition's ClassifyToolResult or OnAuthError hook) to ask the runner to +// perform a one-shot ForceRefresh of the access token and retry the current +// invocation. +// +// Open-source code paths never produce this type, so the runner's retry +// branch is a no-op for editions that don't opt in. +type AuthRefreshRequired struct { + // Cause is the underlying user-facing error. The runner returns Cause + // (not the wrapper) when refresh fails or the retry budget is exhausted, + // so end users always see the original diagnostic instead of an internal + // "auth refresh required" message. + Cause error +} + +// Error returns the underlying cause's message, or a generic fallback when +// Cause is nil. +func (e *AuthRefreshRequired) Error() string { + if e == nil || e.Cause == nil { + return "authentication refresh required" + } + return e.Cause.Error() +} + +// Unwrap exposes the underlying cause to errors.Is / errors.As. +func (e *AuthRefreshRequired) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// As walks the error chain and returns the first *AuthRefreshRequired it +// finds, along with true. Returns nil, false otherwise. +// +// Use this instead of a plain errors.As when callers care about acting on +// the marker (force-refresh + retry) rather than just identifying the +// underlying error class. +func As(err error) (*AuthRefreshRequired, bool) { + if err == nil { + return nil, false + } + var target *AuthRefreshRequired + if stderrors.As(err, &target) { + return target, true + } + return nil, false +} diff --git a/pkg/cmdutil/leaf_merge.go b/pkg/cmdutil/leaf_merge.go new file mode 100644 index 00000000..ebc539c0 --- /dev/null +++ b/pkg/cmdutil/leaf_merge.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import ( + "log/slog" + + "github.com/spf13/cobra" +) + +// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when +// the same-named path does not already exist. Groups recurse. On conflicts the +// dynamic side always wins, because the discovery envelope is the runtime +// authority: hardcoded commands are retained only as a leaf-level fallback for +// behaviour the envelope explicitly does not declare. See +// _docs/discovery-overlay-authority.md. +// +// PRECONDITION: dynamicRoot must be envelope-sourced (carry the +// SourceAnnotation=SourceEnvelope marker set by BuildDynamicCommands via +// MarkEnvelopeSource). Callers that might otherwise pass a helper-fallback +// root with the same name are responsible for evicting it upstream — +// otherwise the "envelope is authority" rule silently promotes helper leaves +// over same-named hardcoded leaves and the overlay loses its ability to +// override routing. The wukong overlay's RegisterProducts gates this call on +// IsEnvelopeSourced(dynamicRoot); new callers must do the same. +// +// Conflict resolution table: +// +// dynamic hardcoded → action +// ------- --------- ----------------------------- +// absent anything graft hardcoded subtree +// leaf leaf dynamic wins (no-op) +// group group recurse +// leaf group dynamic wins, warn +// group leaf dynamic wins, warn +// +// MergeHardcodedLeaves mutates dynamicRoot in place and returns it so callers +// can chain. hardcodedRoot is treated as a donor: grafted children are +// detached from it so their cobra parent pointer points at the new parent. +func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command { + if dynamicRoot == nil || hardcodedRoot == nil { + return dynamicRoot + } + // Snapshot children before mutating hardcodedRoot — RemoveCommand during + // iteration over hardcodedRoot.Commands() is unsafe because cobra returns + // a slice backed by an internal field that is re-sliced on removal. + children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...) + for _, hc := range children { + dyn := findChildByName(dynamicRoot, hc.Name()) + switch { + case dyn == nil: + hardcodedRoot.RemoveCommand(hc) + dynamicRoot.AddCommand(hc) + case IsLeafCmd(hc) && IsLeafCmd(dyn): + // Envelope is authority; hardcoded leaf is ignored. + case !IsLeafCmd(hc) && !IsLeafCmd(dyn): + MergeHardcodedLeaves(dyn, hc) + default: + slog.Warn("overlay: shape mismatch, keeping dynamic", + "name", hc.Name(), + "dynamicIsLeaf", IsLeafCmd(dyn), + "hardcodedIsLeaf", IsLeafCmd(hc)) + } + } + return dynamicRoot +} + +// IsLeafCmd reports whether a command has no subcommands. Leaves carry a RunE +// and are invocation targets; groups merely organise subcommands. +func IsLeafCmd(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + return !cmd.HasSubCommands() +} + +// findChildByName scans parent's direct children for a matching Name(). A +// local helper so pkg/cmdutil stays independent of internal/cobracmd. +func findChildByName(parent *cobra.Command, name string) *cobra.Command { + if parent == nil { + return nil + } + for _, child := range parent.Commands() { + if child.Name() == name { + return child + } + } + return nil +} diff --git a/pkg/cmdutil/leaf_merge_test.go b/pkg/cmdutil/leaf_merge_test.go new file mode 100644 index 00000000..6d7af287 --- /dev/null +++ b/pkg/cmdutil/leaf_merge_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// newGroup returns a group command with the given children attached. +func newGroup(name string, children ...*cobra.Command) *cobra.Command { + cmd := &cobra.Command{Use: name} + cmd.AddCommand(children...) + return cmd +} + +// newLeaf returns a leaf command tagged via Short so tests can verify which +// variant (dynamic or hardcoded) wins without introducing a priority system. +func newLeaf(name, tag string) *cobra.Command { + return &cobra.Command{Use: name, Short: tag} +} + +func TestMergeHardcodedLeaves_NilInputs(t *testing.T) { + t.Parallel() + if got := MergeHardcodedLeaves(nil, nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } + dyn := newGroup("root") + if got := MergeHardcodedLeaves(dyn, nil); got != dyn { + t.Fatal("expected dyn to be returned unchanged") + } + hc := newGroup("root") + if got := MergeHardcodedLeaves(nil, hc); got != nil { + t.Fatal("expected nil when dynamicRoot is nil") + } +} + +func TestMergeHardcodedLeaves_GraftsUnknownLeaf(t *testing.T) { + t.Parallel() + dyn := newGroup("root", newLeaf("kept", "dynamic")) + hc := newGroup("root", newLeaf("extra", "hardcoded")) + + MergeHardcodedLeaves(dyn, hc) + + got := findChildByName(dyn, "extra") + if got == nil { + t.Fatal("expected extra leaf to be grafted onto dyn") + } + if got.Short != "hardcoded" { + t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded") + } + if findChildByName(hc, "extra") != nil { + t.Fatal("expected extra to be detached from hardcodedRoot") + } + if got.Parent() != dyn { + t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn) + } +} + +func TestMergeHardcodedLeaves_DynamicLeafWins(t *testing.T) { + t.Parallel() + dyn := newGroup("root", newLeaf("shared", "dynamic")) + hc := newGroup("root", newLeaf("shared", "hardcoded")) + + MergeHardcodedLeaves(dyn, hc) + + got := findChildByName(dyn, "shared") + if got == nil { + t.Fatal("expected shared leaf to remain on dyn") + } + if got.Short != "dynamic" { + t.Fatalf("shared.Short = %q, want %q (dynamic must win)", got.Short, "dynamic") + } + if findChildByName(hc, "shared") == nil { + t.Fatal("expected hardcoded.shared to remain on hardcodedRoot (not grafted)") + } +} + +func TestMergeHardcodedLeaves_RecurseGroups(t *testing.T) { + t.Parallel() + dyn := newGroup("root", + newGroup("space", + newLeaf("list", "dynamic"), + ), + ) + hc := newGroup("root", + newGroup("space", + newLeaf("list", "hardcoded"), // dynamic wins + newLeaf("create", "hardcoded"), // grafted + ), + newLeaf("ping", "hardcoded"), // grafted top-level + ) + + MergeHardcodedLeaves(dyn, hc) + + space := findChildByName(dyn, "space") + if space == nil { + t.Fatal("expected space group on dyn") + } + if l := findChildByName(space, "list"); l == nil || l.Short != "dynamic" { + t.Fatalf("space.list should remain dynamic, got %+v", l) + } + if c := findChildByName(space, "create"); c == nil || c.Short != "hardcoded" { + t.Fatalf("space.create should be grafted from hardcoded, got %+v", c) + } + if p := findChildByName(dyn, "ping"); p == nil || p.Short != "hardcoded" { + t.Fatalf("ping should be grafted from hardcoded, got %+v", p) + } +} + +func TestMergeHardcodedLeaves_ShapeMismatch_KeepsDynamic(t *testing.T) { + t.Parallel() + // Dynamic declares `cmd` as a group; hardcoded declares `cmd` as a leaf. + // Shape mismatch — dynamic wins, warning logged, hardcoded leaf discarded. + dyn := newGroup("root", + newGroup("cmd", newLeaf("sub", "dynamic")), + ) + hc := newGroup("root", newLeaf("cmd", "hardcoded")) + + MergeHardcodedLeaves(dyn, hc) + + cmd := findChildByName(dyn, "cmd") + if cmd == nil { + t.Fatal("expected cmd to remain on dyn") + } + if IsLeafCmd(cmd) { + t.Fatal("expected dynamic cmd to remain a group") + } + if findChildByName(cmd, "sub") == nil { + t.Fatal("expected cmd.sub to remain") + } +} + +func TestIsLeafCmd(t *testing.T) { + t.Parallel() + leaf := newLeaf("x", "") + group := newGroup("x", newLeaf("child", "")) + if !IsLeafCmd(leaf) { + t.Fatal("expected leaf to be a leaf") + } + if IsLeafCmd(group) { + t.Fatal("expected group to not be a leaf") + } + if IsLeafCmd(nil) { + t.Fatal("expected nil to not be a leaf") + } +} diff --git a/pkg/cmdutil/provenance.go b/pkg/cmdutil/provenance.go new file mode 100644 index 00000000..0115e23a --- /dev/null +++ b/pkg/cmdutil/provenance.go @@ -0,0 +1,54 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import "github.com/spf13/cobra" + +// SourceAnnotation is the cobra.Command.Annotations key used to record where +// a top-level command came from. Edition overlays (e.g. wukong) read this +// annotation to distinguish envelope-authored dynamic commands from +// helper-fallback commands that merely happen to share a name. Keeping the +// key and value literals in one place prevents spelling drift between the +// core (which sets the annotation) and overlays (which read it). +const SourceAnnotation = "dws.source" + +// SourceEnvelope marks a command as authored by the discovery envelope and +// therefore authoritative at runtime. Only commands built from a +// market.ServerDescriptor / CLIOverlay should carry this value. Helper +// fallbacks and other sources must leave the annotation unset. +const SourceEnvelope = "envelope" + +// MarkEnvelopeSource stamps the envelope provenance annotation on cmd. +// Safe to call on commands that may not have an Annotations map yet. +// Callers in core code are the only ones that should invoke this — overlays +// read the annotation but must not fabricate envelope provenance. +func MarkEnvelopeSource(cmd *cobra.Command) { + if cmd == nil { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[SourceAnnotation] = SourceEnvelope +} + +// IsEnvelopeSourced reports whether cmd carries the envelope provenance +// annotation. Commands without the annotation are treated as non-authoritative +// (helper fallbacks, overlay-injected stubs, etc.). +func IsEnvelopeSourced(cmd *cobra.Command) bool { + if cmd == nil || cmd.Annotations == nil { + return false + } + return cmd.Annotations[SourceAnnotation] == SourceEnvelope +} diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index 556105b4..0db286d3 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -55,6 +55,12 @@ type ToolCaller interface { DryRun() bool } +// RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g. +// "$currentUserId") to a concrete string value. Called lazily at RunE time. +// Returning (_, false) is equivalent to "not registered" and falls through +// to the next-lower default source. +type RuntimeDefaultFn func(ctx context.Context) (string, bool) + // Hooks groups all edition-specific behavioural overrides. Zero values // fall back to open-source defaults so the struct is safe to use as-is. type Hooks struct { @@ -88,17 +94,54 @@ type Hooks struct { // ClassifyToolResult inspects raw MCP tool-call content and returns a typed // error (e.g. PATError, CLIError) when the response contains a known // gateway-auth or PAT-permission failure. nil → no special handling. - ClassifyToolResult func(content map[string]any) error + // ClassifyToolResult func(content map[string]any) error // --- product & endpoint --- StaticServers func() []ServerInfo // non-nil → skip Market discovery VisibleProducts func() []string // non-nil → override help visibility RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands + // --- discovery --- + + // DiscoveryURL overrides the Market API endpoint for server list. + // Non-empty → loadDynamicCommands uses FetchServersFromURL(DiscoveryURL) + // instead of the default Market base URL. Provides edition-level isolation. + DiscoveryURL string + + // DiscoveryHeaders returns HTTP headers injected into discovery requests. + // Used to authenticate edition-specific endpoints. + DiscoveryHeaders func() map[string]string + + // SupplementServers returns edition-specific MCP servers NOT registered + // in any Market registry. Always merged into the endpoint map alongside + // Market/cache results, regardless of discovery success or failure. + SupplementServers func() []ServerInfo + + // FallbackServers returns the full server list as a safety net when + // Market discovery + cache both fail. Results are NOT cached so the + // next startup still attempts live discovery. + FallbackServers func() []ServerInfo + // AfterPersistentPreRun runs at the end of the root PersistentPreRunE after // global setup (OAuth flag overrides, log level, output sink). Overlays use // this for clients that bypass the MCP runner (e.g. A2A gateway). AfterPersistentPreRun func(cmd *cobra.Command, args []string) error + + // ClassifyToolResult is called before the framework's default business-error + // detection on MCP tool results. If it returns a non-nil error, that error + // is used instead of the generic CategoryAPI business error. Editions use + // this to return custom error types with specific exit codes (e.g. PAT + // authorization errors with exit code 4). + ClassifyToolResult func(content map[string]any) error + + // --- schema v3: runtime defaults --- + + // RuntimeDefaults returns resolvers for runtimeDefault placeholders (e.g. + // "$currentUserId" → fn). Placeholders not in the map fall through to a + // "not registered" warning. Open-source core returns an empty map; + // overlays populate the whitelist ($currentUserId / $unionId / $corpId / + // $now / $today). See schema v3 §2.3. + RuntimeDefaults func() map[string]RuntimeDefaultFn } var ( diff --git a/pkg/editiontest/contract.go b/pkg/editiontest/contract.go index cc43f11d..2723e4d6 100644 --- a/pkg/editiontest/contract.go +++ b/pkg/editiontest/contract.go @@ -17,6 +17,7 @@ package editiontest import ( + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -85,4 +86,47 @@ func RunContractTests(t *testing.T, h *edition.Hooks) { t.Error("VisibleProducts() must return at least one product when set") } }) + + t.Run("SupplementServers_valid_when_set", func(t *testing.T) { + if h.SupplementServers == nil { + return + } + servers := h.SupplementServers() + if len(servers) == 0 { + t.Error("SupplementServers() must return at least one server when set") + } + for i, s := range servers { + if s.ID == "" || s.Endpoint == "" { + t.Errorf("SupplementServers()[%d]: ID and Endpoint must be non-empty", i) + } + } + }) + + t.Run("FallbackServers_superset_of_supplement", func(t *testing.T) { + if h.FallbackServers == nil || h.SupplementServers == nil { + return + } + fallback := h.FallbackServers() + if len(fallback) == 0 { + t.Error("FallbackServers() must return at least one server when set") + } + fbIDs := make(map[string]bool, len(fallback)) + for _, s := range fallback { + fbIDs[s.ID] = true + } + for _, s := range h.SupplementServers() { + if !fbIDs[s.ID] { + t.Errorf("SupplementServers ID %q not found in FallbackServers", s.ID) + } + } + }) + + t.Run("DiscoveryURL_valid_when_set", func(t *testing.T) { + if h.DiscoveryURL == "" { + return + } + if !strings.HasPrefix(h.DiscoveryURL, "https://") { + t.Errorf("DiscoveryURL must start with https://, got %q", h.DiscoveryURL) + } + }) } diff --git a/skills/SKILL.md b/skills/SKILL.md index 9a8b0d0e..bfc43ceb 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -1,7 +1,7 @@ --- name: dws -description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档/钉钉文档/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、查询听记纪要时使用。 -cli_version: ">=1.0.6" +description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。 +cli_version: ">=1.0.15" --- # 钉钉全产品 Skill @@ -24,39 +24,41 @@ cli_version: ">=1.0.6" | 产品 | 用途 | 参考文件 | |-------------------|------------------------------------------------------|----------------------------------------------------------------| -| `aitable` | AI表格:Base/数据表/字段/记录/附件/模板搜索 | [aitable.md](./references/products/aitable.md) | -| `approval` | 审批:审批表单/发起实例/审批/撤销 | [simple.md](./references/products/simple.md) | -| `attendance` | 考勤:打卡记录/排班查询 | [attendance.md](./references/products/attendance.md) | -| `calendar` | 日历:日程/参与者/会议室/闲忙查询 | [calendar.md](./references/products/calendar.md) | +| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) | +| `attendance` | 考勤:打卡记录/排班查询/考勤规则/汇总统计 | [attendance.md](./references/products/attendance.md) | +| `calendar` | 日历:日程/参与者/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) | | `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/机器人群发/单聊/撤回/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) | -| `contact` | 通讯录:用户查询(当前用户/搜索/详情)/部门查询(搜索/子部门/成员列表) | [contact.md](./references/products/contact.md) | -| `devdoc` | 开放平台文档:搜索开发文档 | [simple.md](./references/products/simple.md) | +| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/部门查询(搜索/成员列表) | [contact.md](./references/products/contact.md) | +| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) | | `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) | -| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论 | [doc.md](./references/products/doc.md) | -| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词 | [minutes.md](./references/products/minutes.md) | +| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名 | [doc.md](./references/products/doc.md) | +| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) | +| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) | +| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) | | `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) | -| `todo` | 待办:创建(含优先级/截止时间)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | -| `workbench` | 工作台:应用管理 | [workbench.md](./references/products/workbench.md) | +| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | ## 意图判断决策树 -用户提到"表格/多维表/AI表格/记录/数据" → `aitable` -用户提到"审批/请假/报销/出差/加班" → `oa` +用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable` 用户提到"考勤/打卡/排班" → `attendance` -用户提到"日程/日历/会议室/约会" → `calendar` +用户提到"日程/日历/会议室/约会/时间建议" → `calendar` 用户提到"群聊/建群/群成员/群管理/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat` 用户提到"通讯录/同事/部门/组织架构" → `contact` 用户提到"开发/API/调用错误 文档" → `devdoc` 用户提到"DING/紧急消息/电话提醒" → `ding` -用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论" → `doc` +用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc` +用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive` 用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes` +用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa` 用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report` -用户提到"待办/TODO/任务提醒" → `todo` -用户提到"工作台/应用管理" → `workbench` +用户提到"待办/TODO/任务提醒/循环待办" → `todo` 关键区分: aitable(数据表格) vs todo(待办任务) 关键区分: report(钉钉日志/日报周报) vs todo(待办任务) 关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警) +关键区分: doc(钉钉文档/富文本协同) vs drive(钉钉云盘/二进制文件) +关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用) > 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md) @@ -68,14 +70,22 @@ cli_version: ">=1.0.6" | 产品 | 命令 | 说明 | |------|------|------| | `aitable` | `base delete` | 删除整个 AI 表格,含全部数据表和记录 | +| `aitable` | `table delete` | 删除数据表(含全部字段/视图/记录) | +| `aitable` | `field delete` | 删除字段(该列所有值同步清空) | +| `aitable` | `view delete` | 删除视图 | | `aitable` | `record delete` | 删除记录(支持批量) | +| `aitable` | `chart delete` / `dashboard delete` | 删除图表/仪表盘 | | `calendar` | `event delete` | 删除日程,所有参与者同步取消 | | `calendar` | `participant delete` | 移除日程参与者 | | `calendar` | `room delete` | 取消会议室预定 | | `chat` | `group members remove` | 移除群成员 | -| `doc` | `delete` | 删除钉钉文档(不可恢复) | -| `doc` | `block delete` | 删除文档块 | +| `chat` | `message recall-by-bot` | 撤回机器人已发消息 | +| `doc` | `block delete` | 删除文档块(不可恢复) | +| `ding` | `message recall` | 撤回已发 DING 消息 | +| `oa` | `approval revoke` | 撤销自己发起的审批实例 | +| `oa` | `approval reject` | 拒绝待审批(需加明确理由) | | `todo` | `task delete` | 删除待办 | +| `minutes` | `replace-text` | 全文批量替换转写与摘要 | ### 确认流程 ``` @@ -92,6 +102,30 @@ Step 3 → 加 --yes 执行命令 3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。 4. 充分阅读产品参考文件,通过编写代码或直接调用指令实现用户意图。 +## 命令发现(flag / 参数以 binary 为准) + +产品参考文档(`references/products/*.md`)里的 flag 列表是**便于理解用途的参考**,不是权威契约。参数名称、默认值、必填约束随服务发现动态变化,**以下两个命令的输出才是调用的事实源**: + +```bash +# 1) 人读视图:看 Usage / Example / Flags +dws --help +# 例:dws calendar event list --help + +# 2) 机读视图:JSON Schema + flag 别名映射 + 必填字段 +dws schema # 列出所有产品及工具 +dws schema . # 规范路径(如 calendar.list_suggested_event_times) +dws schema " " # CLI 路径(如 "calendar event list") +dws schema --jq '.tool.flag_overlay' # 只看 flag 别名 +dws schema --jq '.tool.required' # 只看必填字段 +``` + +**何时用哪条路径:** +- 只需看某个命令怎么调用 → `dws --help` +- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → `dws schema ` +- 参考文档和 `--help` 冲突时 → **以 `--help` / `dws schema` 为准**,文档视为过期 + +`dws schema` 输出的 `flag_overlay[key].alias` 就是实际生效的 flag 名(如 `attendeeUserIds → --attendee-user-ids`);`parameters[key]` 是原始 JSON Schema;`required` 是必填字段数组;`sensitive: true` 表示写/删操作,须先向用户确认再加 `--yes`。 + ## 错误处理 1. 遇到错误,加 `--verbose` 重试一次 2. 若 stderr 出现 `RECOVERY_EVENT_ID=`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环 @@ -102,7 +136,7 @@ Step 3 → 加 --yes 执行命令 ## 详细参考 (按需读取) -- [references/products/](./references/products/) — 各产品命令详细参考 +- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准) - [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照) - [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式 - [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则 diff --git a/skills/references/products/aitable.md b/skills/references/products/aitable.md index ae42246e..8606dd16 100644 --- a/skills/references/products/aitable.md +++ b/skills/references/products/aitable.md @@ -215,11 +215,12 @@ Example: dws aitable field update --base-id --table-id --field-id --name "新字段名" dws aitable field update --base-id --table-id --field-id --config '{"options":[{"name":"A"},{"name":"B"}]}' Flags: - --base-id string Base ID (必填) - --config string 字段配置 JSON (不修改时省略) - --field-id string Field ID (必填) - --name string 新字段名称 (不修改时省略) - --table-id string Table ID (必填) + --ai-config string AI 字段配置 JSON(AI 字段类型专用,可选) + --base-id string Base ID (必填) + --config string 字段配置 JSON (不修改时省略) + --field-id string Field ID (必填) + --name string 新字段名称 (不修改时省略) + --table-id string Table ID (必填) ``` 不可变更字段类型。更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id。 @@ -362,6 +363,328 @@ dws aitable record create --base-id --table-id \ > ⚠️ `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。 +### view (视图管理) + +视图是同一张数据表的筛选/排序/分组/可见字段组合的备用呈现。record query 可以通过视图收敛查询范围。 + +#### 获取视图 +``` +Usage: + dws aitable view get [flags] +Example: + dws aitable view get --base-id --table-id + dws aitable view get --base-id --table-id --view-ids viw1,viw2 +Flags: + --base-id string Base ID (必填) + --table-id string Table ID (必填) + --view-ids string View ID 列表,逗号分隔,单次最多 10 个 +``` + +返回视图配置(过滤、排序、可见字段、分组)。不传 view-ids 返回该表全部视图。 + +#### 创建视图 +``` +Usage: + dws aitable view create [flags] +Example: + dws aitable view create --base-id --table-id --name "进行中看板" --view-type kanban +Flags: + --base-id string Base ID (必填) + --config string 视图配置 JSON:过滤/排序/分组/可见字段(可选,创建后可再 update) + --name string 视图名称 (必填) + --table-id string Table ID (必填) + --view-type string 视图类型:grid/gallery/kanban/gantt/calendar/form 等 (必填) +``` + +> ⚠️ 视图类型参数是 `--view-type`,不是 `--type`。 + +#### 更新视图 +``` +Usage: + dws aitable view update [flags] +Example: + dws aitable view update --base-id --table-id --view-id --name "新视图名" +Flags: + --base-id string Base ID (必填) + --config string 视图配置 JSON:过滤/排序/分组/可见字段 + --name string 新视图名 + --table-id string Table ID (必填) + --view-id string 目标 View ID (必填) +``` + +调整过滤、排序、分组、可见字段时使用。不重建视图即可替换配置。 + +#### 删除视图 +``` +Usage: + dws aitable view delete [flags] +Example: + dws aitable view delete --base-id --table-id --view-id --yes +Flags: + --base-id string Base ID (必填) + --table-id string Table ID (必填) + --view-id string 待删除 View ID (必填) +``` + +不可逆。若是主视图或最后一个视图,删除会失败。 + +### import (数据导入) + +把外部 Excel/CSV 批量写入某张数据表,按两步走:先 upload 拿 importId,再 data 触发导入。 + +#### 准备导入上传 +``` +Usage: + dws aitable import upload [flags] +Example: + dws aitable import upload --base-id --file-name sales.xlsx --file-size 204800 +Flags: + --base-id string Base ID (必填) + --file-name string 文件名,必须含扩展名 (必填) + --file-size int 文件大小(字节),>0 (必填) +``` + +> ⚠️ 参数是 `--file-size`,不是 `--size`。返回 uploadUrl(PUT 上传)与 importId。 + +#### 触发导入 +``` +Usage: + dws aitable import data [flags] +Example: + dws aitable import data --import-id --timeout 60 +Flags: + --import-id string import upload 返回的 importId (必填) + --timeout int 等待导入完成的超时秒数,默认 30 +``` + +把上一步上传到暂存区的文件正式导入为记录;超时未完成时返回处理状态,按 importId 再次查询即可。 + +### dashboard (仪表盘) + +仪表盘 = 多个图表的布局容器;图表绑定数据表/视图。创建仪表盘之前通常先查 `dashboard config-example`,拿到 JSON 骨架,再把图表塞进 `--config` 的布局里。 + +#### 仪表盘配置示例 +``` +Usage: + dws aitable dashboard config-example [flags] +Example: + dws aitable dashboard config-example --format json +``` + +返回仪表盘 `--config` 的 JSON 示例,供 create/update 复制裁剪。 + +#### 获取仪表盘 +``` +Usage: + dws aitable dashboard get [flags] +Example: + dws aitable dashboard get --base-id --dashboard-id +Flags: + --base-id string Base ID (必填) + --dashboard-id string Dashboard ID (必填) +``` + +返回布局和 `charts[].chartId`,可直接喂给 `chart get`。 + +#### 创建仪表盘 +``` +Usage: + dws aitable dashboard create [flags] +Example: + dws aitable dashboard create --base-id --config '' +Flags: + --base-id string Base ID (必填) + --config string 仪表盘配置 JSON(名称、布局、图表列表都在里面) (必填) +``` + +> ⚠️ 没有独立的 `--name`;名称和布局一起放在 `--config` JSON 中。 + +#### 更新仪表盘 +``` +Usage: + dws aitable dashboard update [flags] +Example: + dws aitable dashboard update --base-id --dashboard-id --config '' +Flags: + --base-id string Base ID (必填) + --config string 更新后的仪表盘配置 JSON (必填) + --dashboard-id string Dashboard ID (必填) +``` + +调整布局、增删图表一律改 `--config`。 + +#### 删除仪表盘 +``` +Usage: + dws aitable dashboard delete [flags] +Example: + dws aitable dashboard delete --base-id --dashboard-id --yes +Flags: + --base-id string Base ID (必填) + --dashboard-id string Dashboard ID (必填) + --reason string 删除原因 +``` + +不可逆。 + +#### 查看仪表盘分享状态 +``` +Usage: + dws aitable dashboard share get [flags] +Example: + dws aitable dashboard share get --base-id --dashboard-id +Flags: + --base-id string Base ID (必填) + --dashboard-id string Dashboard ID (必填) +``` + +> ⚠️ 可能返回 404(资源不存在或未开通外链),按可重试错误处理,不要误判为参数拼错。 + +#### 更新仪表盘分享 +``` +Usage: + dws aitable dashboard share update [flags] +Example: + dws aitable dashboard share update --base-id --dashboard-id --enabled true +Flags: + --allow-back-to-doc 是否允许从分享页返回原文档(可选) + --base-id string Base ID (必填) + --dashboard-id string Dashboard ID (必填) + --enabled 是否开启外链分享 (必填) + --share-type string 分享类型(权限/可见范围等,可选) +``` + +开启后返回 shareUrl;关闭后原链接失效。 + +### chart (图表) + +图表挂在某个仪表盘下,绑定数据表(可进一步绑定视图)。新建前通常先查 `chart widgets-example`。 + +#### 图表组件示例 +``` +Usage: + dws aitable chart widgets-example [flags] +Example: + dws aitable chart widgets-example --format json +``` + +返回图表 widget 的 JSON 示例,供 `chart create/update --config` 参考。 + +#### 获取图表 +``` +Usage: + dws aitable chart get [flags] +Example: + dws aitable chart get --base-id --dashboard-id --chart-id +Flags: + --base-id string Base ID (必填) + --chart-id string Chart ID (必填) + --dashboard-id string Dashboard ID (必填) +``` + +#### 创建图表 +``` +Usage: + dws aitable chart create [flags] +Example: + dws aitable chart create --base-id --dashboard-id --config '' +Flags: + --base-id string Base ID (必填) + --config string 图表配置 JSON:数据源、维度、度量、样式 (必填) + --dashboard-id string 挂载到的 Dashboard ID (必填) + --layout string 图表布局 JSON(位置、尺寸,可选) +``` + +> ⚠️ 名称和类型都在 `--config` JSON 里,没有独立 `--name` / `--type`。 + +#### 更新图表 +``` +Usage: + dws aitable chart update [flags] +Example: + dws aitable chart update --base-id --dashboard-id --chart-id --config '' +Flags: + --base-id string Base ID (必填) + --chart-id string Chart ID (必填) + --config string 图表配置 JSON (必填) + --dashboard-id string Dashboard ID (必填) + --layout string 图表布局 JSON(位置、尺寸,可选) +``` + +#### 删除图表 +``` +Usage: + dws aitable chart delete [flags] +Example: + dws aitable chart delete --base-id --dashboard-id --chart-id --yes +Flags: + --base-id string Base ID (必填) + --chart-id string 待删除 Chart ID (必填) + --dashboard-id string Dashboard ID (必填) + --reason string 删除原因 +``` + +不可逆。 + +#### 查看图表分享状态 +``` +Usage: + dws aitable chart share get [flags] +Example: + dws aitable chart share get --base-id --dashboard-id --chart-id +Flags: + --base-id string Base ID (必填) + --chart-id string Chart ID (必填) + --dashboard-id string Dashboard ID (必填) +``` + +返回 `enabled` 与 `shareUrl`,用来判断是否已经对外分享。 + +#### 更新图表分享 +``` +Usage: + dws aitable chart share update [flags] +Example: + dws aitable chart share update --base-id --dashboard-id --chart-id --enabled true +Flags: + --allow-back-to-doc 是否允许从分享页返回原文档(可选) + --base-id string Base ID (必填) + --chart-id string Chart ID (必填) + --dashboard-id string Dashboard ID (必填) + --enabled 是否开启外链分享 (必填) + --share-type string 分享类型(权限/可见范围等,可选) +``` + +### export (数据导出) + +把数据表/视图/整张 Base 导出为 Excel/CSV,下发下载链接;常见是异步任务,首次调用可能只返回 `taskId`,需要按 `taskId` 继续轮询直到拿到 `downloadUrl`。 + +#### 导出数据 +``` +Usage: + dws aitable export data [flags] +Example: + # 第一步:创建任务(按 scope 传必要参数) + dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000 + + # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl + dws aitable export data --base-id --task-id --timeout-ms 3000 +Flags: + --base-id string Base ID (必填) + --scope string 导出范围:all / table / view + --table-id string Table ID(scope=table/view 时必填) + --view-id string View ID(scope=view 时必填) + --format string 导出格式:excel / csv,默认 excel + --task-id string 轮询已有任务的 taskId + --timeout-ms int 单次调用服务端等待时间(毫秒) +``` + +参数约束: + +- `scope=all`:只需 `--base-id` +- `scope=table`:必须同时传 `--table-id` +- `scope=view`:必须同时传 `--table-id + --view-id` + ### template (模板搜索) #### 搜索模板 @@ -392,6 +715,10 @@ dws aitable chart widgets-example --format json # 2) 先拿 dashboard,再拿 chart 详情 dws aitable dashboard get --base-id --dashboard-id --format json dws aitable chart get --base-id --dashboard-id --chart-id --format json + +# 3) 写入/更新都用 --config JSON(名称、布局、图表类型都在里面) +dws aitable dashboard create --base-id --config '' --format json +dws aitable chart update --base-id --dashboard-id --chart-id --config '' --format json ``` 要点: @@ -399,24 +726,20 @@ dws aitable chart get --base-id --dashboard-id --chart- - `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。 - `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。 - `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。 +- dashboard/chart 的 create/update 只有 `--config`,没有独立的 `--name` / `--type`,名称与布局都在 JSON 里。 -### 导出数据(两阶段轮询) - -`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。 +### 导入外部数据(两步走) ```bash -# 第一步:创建任务(按 scope 传必要参数) -dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000 +# 1) 先上传文件拿 importId +dws aitable import upload --base-id --file-name sales.xlsx --file-size 204800 --format json +# 把本地文件 PUT 到返回的 uploadUrl(过期前必须上传完成) -# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl -dws aitable export data --base-id --task-id --timeout-ms 3000 +# 2) 触发导入 +dws aitable import data --import-id --timeout 60 --format json ``` -参数约束 - -- `scope=all`:只需 `base-id` -- `scope=table`:必须 `table-id` -- `scope=view`:必须同时 `table-id + view-id` +要点:`--file-size` 单位字节,必须与实际文件一致;`--timeout` 单位秒,不是毫秒。 ## 意图判断 @@ -446,9 +769,37 @@ dws aitable export data --base-id --task-id --timeout-ms 300 - 修改/更新 → `record update`(需 recordId,先 `record query`) - 删除 → `record delete` [危险](需 recordId) +用户说"视图/筛选视图/看板/画廊": +- 查看 → `view get` +- 新建 → `view create`(`--view-type` 指定视图类型) +- 修改(过滤/排序/分组/可见字段) → `view update` +- 删除 → `view delete` [危险] + +用户说"导入/上传 Excel/CSV": +- 外部文件导入 → `import upload` → PUT 文件 → `import data`(两步) + +用户说"导出/下载表格数据": +- → `export data`(按 scope=all/table/view 传参,异步轮询) + +用户说"仪表盘/dashboard": +- 查看布局 → `dashboard get` +- 查看模板 → `dashboard config-example` +- 新建/修改布局 → `dashboard create` / `dashboard update`(配置都在 `--config` JSON) +- 删除 → `dashboard delete` [危险] +- 对外分享 → `dashboard share get` / `dashboard share update` + +用户说"图表/chart": +- 查看 → `chart get`(需要 dashboardId) +- 查看组件模板 → `chart widgets-example` +- 新建/修改 → `chart create` / `chart update`(配置都在 `--config` JSON) +- 删除 → `chart delete` [危险] +- 对外分享 → `chart share get` / `chart share update` + +用户说"附件/文件字段" → `attachment upload`(不要用钉盘 drive) + 用户说"模板" → `template search` -关键区分: base=表格文件, table=数据表, field=列, record=行 +关键区分: base=表格文件, table=数据表, field=列, record=行, view=视图, dashboard=仪表盘, chart=图表 ## 核心工作流 @@ -476,15 +827,23 @@ dws aitable record create --base-id --table-id \ |------|-------------|------| | `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI | | `base create` | `baseId` | 后续命令 + 文档 URI | -| `base get` | `tables[].tableId` | --table-id | -| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete | +| `base get` | `tables[].tableId`、`dashboards[].dashboardId` | --table-id / --dashboard-id | +| `table get` | `fields[].fieldId`、`views[].viewId` | record 操作的 cells key;field get/update/delete;view 操作 | +| `view get` | `viewId` | record query 按视图收敛;view update/delete | | `record query` | `recordId` | record update/delete | +| `dashboard get` | `charts[].chartId` | chart get/update/delete/share | +| `import upload` | `importId`、`uploadUrl` | PUT 文件后调 import data | +| `export data` (首次) | `taskId` | 下一轮 export data 轮询 | +| `attachment upload` | `fileToken` | record create/update 写入 attachment 字段 | | `template search` | `templateId` | base create --template-id,拼接模板预览 URI | ## 注意事项 -- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称 +- 所有操作使用 ID(baseId/tableId/fieldId/recordId/viewId/dashboardId/chartId),不使用名称 - records 的 cells key 是 fieldId,不是字段名称 +- dashboard/chart 的 create/update 只有 `--config`(JSON 内含名称/布局/类型),没有独立 `--name` +- view 创建的类型参数是 `--view-type`,不是 `--type` +- import upload 的文件大小参数是 `--file-size`,不是 `--size` ## `--filters` 筛选语法排错与使用规范(极易出错) diff --git a/skills/references/products/calendar.md b/skills/references/products/calendar.md index 0dde78a1..867eacd2 100644 --- a/skills/references/products/calendar.md +++ b/skills/references/products/calendar.md @@ -30,11 +30,17 @@ Usage: Example: dws calendar event create --title "Q1 复盘会" \ --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" + dws calendar event create --title "Q1 复盘会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --attendees userId1,userId2 --timezone "Asia/Shanghai" --desc "Q1 复盘" Flags: - --desc string 日程描述 - --end string 结束时间 ISO-8601 (必填) - --start string 开始时间 ISO-8601 (必填) - --title string 日程标题 (必填) + --attendees string 参会人 userId 列表 (逗号分隔) + --desc string 日程描述 + --end string 结束时间 ISO-8601 (必填) + --open-dingtalk-ids string 参会人 openDingTalkId 列表 (逗号分隔) + --start string 开始时间 ISO-8601 (必填) + --timezone string 时区 (如 Asia/Shanghai) + --title string 日程标题 (必填) ``` ### 修改日程 @@ -43,11 +49,14 @@ Usage: dws calendar event update [flags] Example: dws calendar event update --id --title "新标题" + dws calendar event update --id --desc "议程调整" --timezone "Asia/Shanghai" Flags: - --end string 新结束时间 - --id string 日程 ID (必填) - --start string 新开始时间 - --title string 新标题 + --desc string 新描述 + --end string 新结束时间 + --id string 日程 ID (必填) + --start string 新开始时间 + --timezone string 时区 (如 Asia/Shanghai) + --title string 新标题 ``` ### 删除日程 @@ -60,6 +69,23 @@ Flags: --id string 日程 ID (必填) ``` +### 推荐会议时间 +``` +Usage: + dws calendar event suggest [flags] +Example: + dws calendar event suggest --users , \ + --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 30 +Flags: + --duration string 会议时长(分钟) + --end string 候选时段结束时间 ISO-8601 (必填) + --start string 候选时段开始时间 ISO-8601 (必填) + --timezone string 时区 (如 Asia/Shanghai) + --users string 参会人 userId 列表 (必填) +``` + +基于参会人闲忙数据给出排名后的候选时段,比 `busy search` 直接给出原始忙闲更适合"帮我约"场景。 + ### 查看参与者 ``` Usage: @@ -157,6 +183,7 @@ Flags: - 创建/约 → `event create` - 修改/改时间 → `event update` - 取消/删除 → `event delete` +- 帮我约/找个大家都有空的时间 → `event suggest` 用户说"参会人/与会者": - 查看 → `participant list` @@ -170,7 +197,8 @@ Flags: - 分组 → `room list-groups`,取 groupId 后 `room search --group-id` 用户说"有空吗/忙不忙/闲忙": -- 查询 → `busy search` +- 要原始闲忙块 → `busy search` +- 要直接给时段候选 → `event suggest` ## 核心工作流 diff --git a/skills/references/products/chat.md b/skills/references/products/chat.md index 47ec2465..edcc3f18 100644 --- a/skills/references/products/chat.md +++ b/skills/references/products/chat.md @@ -9,21 +9,20 @@ | 子命令 | 用途 | |-------|------| | `group create` | 创建内部群 | -| `group create-org` | 创建企业全员群 | -| `group members list` | 查看群成员列表 | +| `group members` | 查看群成员列表 | | `group members add` | 添加群成员 | | `group members remove` | 移除群成员(⚠️ 危险操作) | | `group members add-bot` | 添加机器人到群 | | `group rename` | 修改群名称 | | `search` | 搜索群会话 | | `search-common` | 搜索共同群 | +| `conversation-info` | 获取会话基础信息(单聊/群聊) | ### message (会话消息管理) | 子命令 | 用途 | |-------|------| | `message send` | 以当前用户身份发群消息或单聊消息 | -| `message send-personal` | 发送个人消息(⚠️ 敏感操作) | | `message list` | 拉取群聊或单聊会话消息 | | `message list-all` | 按时间范围拉取当前用户所有会话消息 | | `message list-topic-replies` | 拉取群话题回复消息列表 | @@ -32,7 +31,6 @@ | `message list-focused` | 拉取特别关注人的消息 | | `message list-unread-conversations` | 获取未读会话列表 | | `message search` | 按关键词搜索消息 | -| `message info` | 获取会话信息 | | `message send-by-bot` | 机器人发消息(群聊或批量单聊) | | `message recall-by-bot` | 机器人撤回消息 | | `message send-by-webhook` | 自定义机器人 Webhook 发消息 | @@ -43,8 +41,6 @@ | 子命令 | 用途 | |-------|------| | `bot search` | 搜索我的机器人 | -| `bot create` | 创建企业机器人 | -| `bot search-groups` | 搜索机器人所在群 | --- @@ -64,31 +60,15 @@ Flags: --- -## group create-org — 创建企业全员群 - -创建面向企业组织的群,成员通过 userId 列表指定。 - -``` -Usage: - dws chat group create-org [flags] -Example: - dws chat group create-org --name "全员通知群" --users userId1,userId2 -Flags: - --name string 群名称 (必填) - --users string 成员 userId 列表,逗号分隔 (必填) -``` - ---- - -## group members list — 查看群成员列表 +## group members — 查看群成员列表 分页查询指定群聊的成员。 ``` Usage: - dws chat group members list [flags] + dws chat group members [flags] Example: - dws chat group members list --id + dws chat group members --id Flags: --cursor string 分页游标,首次从 0 开始 --id string 群 ID / openconversation_id (必填) @@ -227,6 +207,12 @@ Flags: --at-all @所有人(仅群聊时生效,可选,默认 false) --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选) --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容 + --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断) + --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id) + --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用) + --file-name string 文件消息的文件名 + --file-size string 文件消息的文件大小(字节) + --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等) 注意: - --text 和位置参数二选一,--text 优先 @@ -234,33 +220,7 @@ Flags: - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --at-all 和 --at-users 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2> - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user -``` - ---- - -## message send-personal — 发送个人消息 - -> ⚠️ 敏感操作:执行前必须向用户确认,同意后才加 `--yes`。 - -发送个人消息到指定会话或指定用户。支持 @指定人。 - -``` -Usage: - dws chat message send-personal [flags] -Example: - dws chat message send-personal --id --content "你好" --type text - dws chat message send-personal --open-id --content "消息内容" --type text - dws chat message send-personal --id --content "内容" --at-all -Flags: - --content string 消息内容 (必填) - --type string 消息类型,如 text、markdown (必填) - --id string 群聊会话 ID openConversationId(与 --open-id 二选一) - --open-id string 接收人 openDingTalkId(与 --id 二选一) - --at-all @所有人(可选) - --at-users string @指定人的 openDingTalkId 列表,逗号分隔(可选) - -注意: - - --id(群聊会话)和 --open-id(指定用户 openDingTalkId)二选一 + - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file ``` --- @@ -457,22 +417,22 @@ Flags: --- -## message info — 获取会话信息 +## conversation-info — 获取会话基础信息 -获取指定群聊或单聊会话的详情信息。 +按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。 ``` Usage: - dws chat message info [flags] + dws chat conversation-info [flags] Example: - dws chat message info --id - dws chat message info --open-id + dws chat conversation-info --group + dws chat conversation-info --open-dingtalk-id Flags: - --id string 群聊会话 ID openConversationId(与 --open-id 二选一) - --open-id string 用户 openDingTalkId(单聊时与 --id 二选一) + --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一) + --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一) 注意: - - --id(群聊)和 --open-id(单聊用户 openDingTalkId)二选一 + - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一 ``` --- @@ -515,37 +475,6 @@ Flags: --- -## bot create — 创建企业机器人 - -``` -Usage: - dws chat bot create [flags] -Example: - dws chat bot create --name "日报提醒机器人" --desc "负责每日日报提醒" -Flags: - --name string 机器人名称 (必填) - --desc string 机器人描述(可选) -``` - ---- - -## bot search-groups — 搜索机器人所在群 - -搜索指定机器人已加入的群列表。 - -``` -Usage: - dws chat bot search-groups [flags] -Example: - dws chat bot search-groups --keyword "项目" - dws chat bot search-groups --keyword "冲刺" --cursor -Flags: - --keyword string 搜索关键词 (必填) - --cursor string 分页游标(首页留空,翻页传返回的 cursor) -``` - ---- - ## message send-by-bot — 机器人发消息 群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。 @@ -612,9 +541,8 @@ Flags: ## 意图判断 用户说"建群/创建群聊" → `chat group create` -用户说"创建企业全员群/组织群" → `chat group create-org` 用户说"搜索群/找群" → `chat search` -用户说"群成员/看群里有谁" → `chat group members list` +用户说"群成员/看群里有谁" → `chat group members` 用户说"拉人进群/加群成员" → `chat group members add` 用户说"踢人/移除群成员" → `chat group members remove` 用户说"加机器人到群" → `chat group members add-bot` @@ -626,7 +554,6 @@ Flags: 用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations` 用户说"发群消息(以个人身份)" → `chat message send --group` 用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时) -用户说"发个人消息/个人通知" → `chat message send-personal`(⚠️ 敏感操作,需确认) 用户说"机器人发消息/机器人群发" → `chat message send-by-bot` 用户说"机器人撤回消息" → `chat message recall-by-bot` 用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook` @@ -634,12 +561,10 @@ Flags: 用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all` 用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused` 用户说"查看我的机器人" → `chat bot search` -用户说"创建机器人" → `chat bot create` 用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search` 用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common` 用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations` -用户说"获取会话信息/会话详情" → `chat message info` -用户说"机器人在哪些群/机器人的群" → `chat bot search-groups` +用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info` 关键区分: - `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页 @@ -652,15 +577,13 @@ Flags: - `chat message list-focused` — 拉取特别关注人的消息,cursor 分页 - `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页 - `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息 -- `chat message send-personal` — 发送个人消息,支持通过 openConversationId 或 openDingTalkId 指定目标(⚠️ 敏感操作) - `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群) - `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在) - `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag - `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息 - `chat message recall-by-bot` — 通过机器人撤回已发送的消息 -- `chat message info` — 获取指定会话的详情信息 -- `chat bot create` — 创建新的企业机器人 -- `chat bot search-groups` — 搜索机器人所在群列表 +- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等) +- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot ## 核心工作流 @@ -715,16 +638,18 @@ dws chat message recall-by-bot --robot-code --group --format json ``` -### 创建并使用机器人(完整流程) +### 将已有机器人加入群并发消息(完整流程) + +机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。 ```bash -# Step 1: 创建机器人 -dws chat bot create --name "项目提醒机器人" --desc "项目状态提醒" --format json +# Step 1: 搜索我的机器人 — 提取 robotCode +dws chat bot search --name "项目提醒" --format json # Step 2: 搜索群 — 提取 openConversationId dws chat search --query "项目群" --format json -# Step 3: 将机器人添加到群 +# Step 3: 将机器人添加到群(需当前用户对该群有管理权限) dws chat group members add-bot --id --robot-code --format json # Step 4: 机器人发消息 @@ -775,8 +700,8 @@ dws chat message send --group \ | `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id | | `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id | | `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code | -| `chat bot create` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code | | `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | +| `chat conversation-info` | `openConversationId` / 成员信息 | 作为 message send/list 等后续操作的会话上下文确认 | | `chat message search` | `nextCursor` | 下次 message search 的 --cursor | | `chat search-common` | `openConversationId` | message send/list 等的 --group | | `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 | @@ -785,7 +710,6 @@ dws chat message send --group \ - `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取 - `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id` -- `chat message send-personal` 为敏感操作(isSensitive),执行前需用户明确确认 - `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor - `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一 - `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识 diff --git a/skills/references/products/contact.md b/skills/references/products/contact.md index 4f00a6d2..089edc69 100644 --- a/skills/references/products/contact.md +++ b/skills/references/products/contact.md @@ -17,9 +17,9 @@ Example: Usage: dws contact user search [flags] Example: - dws contact user search --keyword "张三" + dws contact user search --query "张三" Flags: - --keyword string 搜索关键词 (必填) + --query string 搜索关键词 (必填) ``` #### 按手机号搜索用户 @@ -49,9 +49,9 @@ Flags: Usage: dws contact dept search [flags] Example: - dws contact dept search --keyword "技术部" + dws contact dept search --query "技术部" Flags: - --keyword string 搜索关键词 (必填) + --query string 搜索关键词 (必填) ``` #### 查看部门成员 @@ -79,10 +79,10 @@ Flags: dws contact user get-self --format json # 2. 按名字搜索同事 — 提取 userId -dws contact user search --keyword "张三" --format json +dws contact user search --query "张三" --format json # 3. 查看部门结构 — 提取 deptId -dws contact dept search --keyword "技术部" --format json +dws contact dept search --query "技术部" --format json # 4. 查看部门成员 dws contact dept list-members --ids --format json @@ -93,7 +93,6 @@ dws contact dept list-members --ids --format json | 操作 | 提取 | 用于 | |------|------|------| | `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 | -| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) | | `dept search` | `deptId` | dept list-members 的 --ids | ## 注意事项 diff --git a/skills/references/products/devdoc.md b/skills/references/products/devdoc.md new file mode 100644 index 00000000..e6e3e239 --- /dev/null +++ b/skills/references/products/devdoc.md @@ -0,0 +1,48 @@ +# 开放平台文档 (devdoc) 命令参考 + +搜索钉钉**开放平台**开发文档,用于回答开发者关于 OpenAPI、字段、错误码、接入指南、配额等技术问题。 + +## 命令总览 + +### 搜索开发文档 +``` +Usage: + dws devdoc article search [flags] +Example: + dws devdoc article search --query "OAuth2 接入" + dws devdoc article search --query "消息卡片" --page 2 --size 5 + dws devdoc article search --query "机器人" --size 10 +Flags: + --query string 搜索关键词 (必填) + --page int 分页页码 (从 1 开始,默认 1) + --size int 分页大小 (默认 10) +``` + +## 意图判断 + +用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节: +- 走 `devdoc article search`,把用户问的关键短语作为 `--query` + +关键区分: +- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容) +- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览 + +## 核心工作流 + +```bash +# 开发者问"OAuth2 怎么接" +dws devdoc article search --query "OAuth2 接入" --format json + +# 命中结果多时翻页 +dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json + +# 查错误码 / 字段含义 +dws devdoc article search --query "errcode 40078" --format json +``` + +## 注意事项 + +- `--query` 必填;建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写 +- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词 +- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考 +- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档** diff --git a/skills/references/products/doc.md b/skills/references/products/doc.md index 5fa71e6e..52ebff5a 100644 --- a/skills/references/products/doc.md +++ b/skills/references/products/doc.md @@ -80,6 +80,22 @@ Flags: --markdown string 文档初始 Markdown 内容 ``` +### 创建其他类型文件 (表格/脑图/白板/多维表/画板) +``` +Usage: + dws doc file create [flags] +Example: + dws doc file create --name "项目周报" --type adoc + dws doc file create --name "数据统计" --type axls --folder + dws doc file create --name "思维导图" --type amind --workspace + dws doc file create --name "子文件夹" --type folder +Flags: + --name string 文件名称 (必填) + --type string 文件类型 (必填): adoc=文档, axls=表格, appt=演示, adraw=白板, amind=脑图, able=多维表, folder=文件夹 + --folder string 目标文件夹 ID 或 URL + --workspace string 目标知识库 ID 或 URL +``` + ### 更新文档内容 ``` Usage: @@ -135,6 +151,45 @@ Flags: --workspace string 目标知识库 ID ``` +### 复制文档/文件 +``` +Usage: + dws doc copy [flags] +Example: + dws doc copy --node --folder + dws doc copy --node --workspace + dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/" --folder +Flags: + --node string 源文档/文件 ID 或 URL (必填) + --folder string 目标文件夹 ID 或 URL + --workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录) +``` + +### 移动文档/文件 +``` +Usage: + dws doc move [flags] +Example: + dws doc move --node --folder + dws doc move --node --workspace +Flags: + --node string 源文档/文件 ID 或 URL (必填) + --folder string 目标文件夹 ID 或 URL + --workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录) +``` + +### 重命名文档/文件 +``` +Usage: + dws doc rename [flags] +Example: + dws doc rename --node --name "新名称" + dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/" --name "项目周报 v2" +Flags: + --node string 文档/文件 ID 或 URL (必填) + --name string 新名称 (必填) +``` + ### 查询块元素 ``` Usage: @@ -229,6 +284,24 @@ Flags: --mention string 被 @ 的用户 uid 列表,逗号分隔 ``` +### 创建划词评论 (内联评论) +``` +Usage: + dws doc comment create-inline [flags] +Example: + dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" + dws doc comment create-inline --node --block-id --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文" + dws doc comment create-inline --node --block-id --start 0 --end 10 --content "请review" --mention uid1,uid2 +Flags: + --node string 目标文档的标识,支持传入 URL 或 ID (必填) + --block-id string 评论锚定所在的块 ID (必填,可通过 dws doc block list 获取) + --start int 选中文本在块内的起始字符偏移量,从 0 开始 (必填) + --end int 选中文本在块内的结束字符偏移量,必须大于 start (必填) + --content string 评论的文字内容,纯文本 (必填) + --selected-text string 选中文本内容,填写后评论列表会展示「引用原文:xxx」 + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + ### 回复文档评论 ``` Usage: @@ -282,12 +355,24 @@ Flags: - 元信息 → `info` 用户说"写文档/创建文档": -- 新建 → `create` +- 新建纯文档 (adoc) → `create` - 追加内容 → `update --mode append` - 覆盖替换 → `update --mode overwrite` +用户说"新建表格/脑图/白板/多维表/演示文稿": +- 用 `file create --type` 指定类型 (axls/amind/adraw/able/appt/adraw) + 用户说"建文件夹/新建目录": -- 创建 → `folder create` +- 创建 → `folder create` 或 `file create --type folder` + +用户说"复制文档/拷贝一份": +- 复制 → `copy` (需源 --node 和目标 --folder/--workspace) + +用户说"移动文档/换个目录": +- 移动 → `move` (需源 --node 和目标 --folder/--workspace) + +用户说"改文档名字/重命名": +- 改名 → `rename` (需 --node 和新 --name) 用户说"上传文件/传文件/上传到文档/上传到知识库": - 上传 → `upload`(需本地文件路径) @@ -390,7 +475,7 @@ dws doc block delete --node --block-id --yes # 1. 查看文档的所有评论 dws doc comment list --node --format json -# 2. 在文档上创建评论 +# 2. 在文档上创建全文评论 dws doc comment create --node --content "这里需要补充数据来源" --format json # 3. 创建评论并 @ 相关人 @@ -398,11 +483,34 @@ dws doc comment create --node --content "这里需要补充数据来源 # 再将 userId 传入 --mention dws doc comment create --node --content "请确认这部分内容" --mention , --format json -# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取) +# 4. 对某段文字创建划词评论(需先 block list 拿 blockId 和字符偏移) +dws doc block list --node --format json +dws doc comment create-inline --node --block-id --start 0 --end 12 \ + --content "这里的数据要复核" --selected-text "被选中的原文片段" --format json + +# 5. 回复某条评论(commentKey 从 list 或 create 返回中获取) dws doc comment reply --node --comment-key --content "已修改" --format json -# 5. 用表情回复评论 +# 6. 用表情回复评论 dws doc comment reply --node --comment-key --content "比心" --emoji --format json + +# ── 工作流 8: 创建非文档类型文件 ── + +# 创建表格 / 脑图 / 白板 / 多维表 / 演示文稿 +dws doc file create --name "销售数据" --type axls --folder --format json +dws doc file create --name "需求脑图" --type amind --workspace --format json +dws doc file create --name "Q1 立项会" --type appt --format json + +# ── 工作流 9: 整理文档结构 (复制 / 移动 / 重命名) ── + +# 1. 把模板复制到目标目录作为新工作件 +dws doc copy --node --folder --format json + +# 2. 把文档从个人空间挪到团队知识库 +dws doc move --node --workspace --format json + +# 3. 重命名 +dws doc rename --node --name "项目周报 v2" --format json ``` ## 上下文传递表 @@ -418,8 +526,11 @@ dws doc comment reply --node --comment-key --content "比 | `upload` | `nodeId` / URL | 上传后文件的访问链接 | | `download` | 本地文件路径 | 下载后的文件保存位置 | | `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key | -| `comment create` | `commentKey` | comment reply 的 --comment-key | -| `contact user search` | `userId` | comment create/reply 的 --mention | +| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key | +| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 | +| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention | +| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) | +| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node | ## nodeId 双格式说明 @@ -448,6 +559,10 @@ dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE - `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档 - `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作 - `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录 +- `create` 只能建"文档"(adoc);要建表格/脑图/白板/多维表/演示/文件夹,用 `file create --type` +- `copy` 需要对源节点有"阅读"权限、对目标目录有"编辑"权限;`move` 需要对源节点有"管理"权限 +- `copy` / `move` 不传 `--folder` 时,`--workspace` 表示放到知识库根目录;两者都不传则回落到"我的文档" +- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始) ## 自动化脚本 diff --git a/skills/references/products/drive.md b/skills/references/products/drive.md new file mode 100644 index 00000000..2d1a156f --- /dev/null +++ b/skills/references/products/drive.md @@ -0,0 +1,177 @@ +# 钉盘 (drive) 命令参考 + +钉盘 = DingTalk Drive,用于云端文件存储 / 上传 / 下载 / 目录管理。不是在线文档编辑;要编辑文档请用 [doc](./doc.md)。 + +## 命令总览 + +### 列出钉盘目录 +``` +Usage: + dws drive list [flags] +Example: + dws drive list + dws drive list --parent-id + dws drive list --parent-id --max 20 --order-by modifiedTime --order desc +Flags: + --parent-id string 父目录 ID (不传则列根目录) + --space-id string 钉盘空间 ID (一般无需指定) + --max int 每页数量 (默认 20) + --next-token string 分页游标 (从上次结果的 nextToken 获取) + --order-by string 排序字段: name / createdTime / modifiedTime + --order string 排序方向: asc / desc + --thumbnail 是否返回缩略图链接 +``` + +### 获取文件元信息 +``` +Usage: + dws drive info [flags] +Example: + dws drive info --file-id +Flags: + --file-id string 文件或文件夹 ID (必填) + --space-id string 钉盘空间 ID (一般无需指定) +``` + +### 创建文件夹 +``` +Usage: + dws drive mkdir [flags] +Example: + dws drive mkdir --name "项目资料" + dws drive mkdir --name "子目录" --parent-id +Flags: + --name string 文件夹名称 (必填) + --parent-id string 父目录 ID (不传则建在根目录) + --space-id string 钉盘空间 ID (一般无需指定) +``` + +### 获取下载临时链接 +``` +Usage: + dws drive download [flags] +Example: + dws drive download --file-id +Flags: + --file-id string 文件 ID (必填) + --space-id string 钉盘空间 ID (一般无需指定) +``` + +### 获取上传凭证 (上传第一步) +``` +Usage: + dws drive upload-info [flags] +Example: + dws drive upload-info --file-name "report.pdf" --file-size 102400 + dws drive upload-info --file-name "slides.pptx" --file-size 512000 --parent-id +Flags: + --file-name string 文件名含后缀 (必填) + --file-size int 文件大小,单位字节 (必填) + --mime-type string MIME 类型 (可选,服务端会自动推断) + --parent-id string 父目录 ID (不传则上传到根目录) + --space-id string 钉盘空间 ID (一般无需指定) +``` + +### 提交上传 (上传第三步) +``` +Usage: + dws drive commit [flags] +Example: + dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id +Flags: + --file-name string 文件名含后缀 (必填,须与 upload-info 一致) + --file-size int 文件大小,单位字节 (必填,须与 upload-info 一致) + --upload-id string upload-info 返回的 uploadId (必填) + --parent-id string 父目录 ID (必填时须与 upload-info 一致) + --space-id string 钉盘空间 ID (一般无需指定) + --conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME) +``` + +## 意图判断 + +用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list` +用户说"钉盘文件详情/文件信息" → `info` (需 fileId) +用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir` +用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET +用户说"上传文件到钉盘/把本地文件传钉盘": +- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit` +- **没有**一条聚合命令可以完成;必须完整走完三步 + +关键区分: +- drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容) +- 把图片/文件发到群里一般走 drive 上传拿链接 → chat 发送 Markdown 链接 (见 [chat.md](./chat.md) 的 `drive → chat` 工作流) + +## 核心工作流 + +```bash +# ── 工作流 1: 浏览钉盘 ── + +# 1. 看根目录 +dws drive list --format json + +# 2. 进入子目录 (parentId 取自上一步的 dentryUuid) +dws drive list --parent-id --format json + +# 3. 看单个文件的元信息 +dws drive info --file-id --format json + +# ── 工作流 2: 上传本地文件到钉盘 (三步不能省) ── + +# Step 1: 拿上传凭证 +dws drive upload-info --file-name "report.pdf" --file-size 102400 --parent-id --format json +# → 返回: resourceUrl (预签名 URL), headers, uploadId + +# Step 2: Agent 自己发 HTTP PUT 把文件二进制推到 resourceUrl +# 请求头必须携带返回的 headers 全部键值对;期望 HTTP 200 +curl -X PUT -T ./report.pdf -H ": " "" + +# Step 3: 提交入库 +dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id \ + --parent-id --format json +# → 返回: dentryUuid (= 后续 download/info 用的 fileId) + +# ── 工作流 3: 下载钉盘文件到本地 (两步) ── + +# Step 1: 拿临时下载 URL +dws drive download --file-id --format json +# → 返回: resourceUrl (带签名的临时下载 URL), expirationSeconds + +# Step 2: Agent 自己发 HTTP GET 下载二进制 +curl -o ./report.pdf "" + +# ── 工作流 4: 建目录后批量上传 ── + +# 1. 建目录 → 拿 folderId +dws drive mkdir --name "2026 Q1 归档" --format json + +# 2. 再走 "工作流 2" 把每个文件上传到该目录 +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `list` | `dentryUuid` (folder 类) | 下次 list 的 --parent-id、upload-info / commit 的 --parent-id、mkdir 的 --parent-id | +| `list` | `dentryUuid` (file 类) | info / download 的 --file-id | +| `list` | `nextToken` | 下次 list 的 --next-token | +| `mkdir` | `dentryUuid` | 作为父目录传给 upload-info / commit 的 --parent-id | +| `upload-info` | `resourceUrl` + `headers` | Agent 自行执行 HTTP PUT 上传二进制 | +| `upload-info` | `uploadId` | commit 的 --upload-id | +| `commit` | `dentryUuid` | download / info / chat message 发送图片链接的 --file-id | +| `download` | `resourceUrl` | Agent 自行执行 HTTP GET 下载;也可作为 Markdown 图片/附件链接发送到 chat | + +## 注意事项 + +- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败 +- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit` +- 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info` +- `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET +- `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配 +- `--conflict-handler` 默认 `AUTO_RENAME` (自动重命名);`OVERWRITE` 覆盖同名文件前必须和用户确认 +- `--space-id` 绝大多数场景不需要传;默认会用用户主钉盘空间 +- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾 + +## 相关产品 + +- [doc](./doc.md) — 钉钉在线文档 / 知识库(文字、表格、脑图等富文本节点),和 drive 的裸文件存储不同 +- [chat](./chat.md) — 结合 drive 发送图片 / 附件消息到群聊(Markdown 链接语法) diff --git a/skills/references/products/minutes.md b/skills/references/products/minutes.md index 0ecfd74a..66436845 100644 --- a/skills/references/products/minutes.md +++ b/skills/references/products/minutes.md @@ -1,5 +1,7 @@ # AI听记 (minutes) 命令参考 +AI 听记:列表 / 详情 / 摘要 / 待办 / 文字稿 / 思维导图 / 发言人 / 热词 / 文件上传。 + ## 命令总览 ### 查询我创建的听记列表 @@ -143,53 +145,126 @@ Flags: --title string 新标题 (必填) ``` -### 发起听记(开始录音) +### 覆盖听记 AI 摘要 ``` Usage: - dws minutes record start [flags] + dws minutes update summary [flags] Example: - dws minutes record start - dws minutes record start --session-id + dws minutes update summary --id --content "修订后的摘要 Markdown" Flags: - --session-id string AI 助理会话 ID (可选) + --id string 听记 taskUuid (必填) + --content string 新的摘要正文 (必填),会整体覆盖原摘要 ``` -### 暂停听记录音 +将 AI 生成的摘要替换成定制版本,适合人工修订或按业务口径重写后回写。 + +### 全文替换听记文本 ``` Usage: - dws minutes record pause [flags] + dws minutes replace-text [flags] Example: - dws minutes record pause --id - dws minutes record pause --id --session-id + dws minutes replace-text --id --search "小钉" --replace "DingTalk" Flags: - --id string 听记 taskUuid (必填) - --session-id string AI 助理会话 ID (可选) + --id string 听记 taskUuid (必填) + --search string 需要替换的原文 (必填) + --replace string 替换后的文本 (必填) ``` -### 恢复听记录音 +在转写段落与摘要中一次性替换所有命中文本,适合纠正系统性的识别错误(如专有名词)。 + +### 替换发言人标签 ``` Usage: - dws minutes record resume [flags] + dws minutes speaker replace [flags] Example: - dws minutes record resume --id - dws minutes record resume --id --session-id + dws minutes speaker replace --id --from "说话人1" --to "张三" + dws minutes speaker replace --id --from "说话人1" --to "张三" --target-uid Flags: - --id string 听记 taskUuid (必填) - --session-id string AI 助理会话 ID (可选) + --id string 听记 taskUuid (必填) + --from string 原发言人昵称,如 "说话人1" (必填) + --to string 新发言人昵称 (必填) + --target-uid string 绑定到指定钉钉 userId (可选) ``` -### 结束听记录音 +修正自动分离的发言人标签,可顺带把该说话人对应到真实用户。 + +### 添加个人热词 ``` Usage: - dws minutes record stop [flags] + dws minutes hot-word add [flags] Example: - dws minutes record stop --id - dws minutes record stop --id --session-id + dws minutes hot-word add --words "钉钉,悟空,DWS" Flags: - --id string 听记 taskUuid (必填) - --session-id string AI 助理会话 ID (可选) + --words string 热词列表,逗号分隔 (必填) ``` +把专有名词/业务术语写入个人热词库,后续听记的语音识别会优先匹配,用来兜底高频误识别。 + +### 生成思维导图 +``` +Usage: + dws minutes mind-graph create [flags] +Example: + dws minutes mind-graph create --id +Flags: + --id string 听记 taskUuid (必填) +``` + +异步任务:提交后返回 jobId,需要用 `mind-graph status` 轮询结果。 + +### 查询思维导图状态 +``` +Usage: + dws minutes mind-graph status [flags] +Example: + dws minutes mind-graph status --id +Flags: + --id string 听记 taskUuid (必填) +``` + +轮询 `mind-graph create` 的生成任务,就绪后返回思维导图内容。 + +### 创建文件上传会话 +``` +Usage: + dws minutes upload create [flags] +Example: + dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘" +Flags: + --file-name string 本地文件名 (必填) + --file-size string 文件大小,单位字节 (必填) + --title string 听记标题 (可选) + --template-id string 摘要模板 ID (可选) + --input-language string 语言,如 zh_CN / en_US (可选) + --enable-message-card string 是否推送消息卡片: true/false (可选) +``` + +三步上传的第 1 步:返回 sessionId 和预签名 URL,由调用方把本地音视频文件 HTTP PUT 到该 URL。 + +### 完成上传并生成听记 +``` +Usage: + dws minutes upload complete [flags] +Example: + dws minutes upload complete --session-id +Flags: + --session-id string upload create 返回的 sessionId (必填) +``` + +三步上传的第 3 步:通知服务端文件已上传完毕,触发转写与 AI 处理,返回新的 taskUuid。 + +### 取消上传 +``` +Usage: + dws minutes upload cancel [flags] +Example: + dws minutes upload cancel --session-id +Flags: + --session-id string upload create 返回的 sessionId (必填) +``` + +上传中途放弃或上游出错时释放会话,避免残留。 + ## 意图判断 用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选) @@ -197,23 +272,23 @@ Flags: 用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选) 用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数 用户说"听记详情/听记信息" → `get info` +用户说"批量看听记/一次查多篇" → `get batch`(`--ids` 传逗号分隔 taskUuid) 用户说"摘要/总结/会议纪要" → `get summary` 用户说"关键字/关键词" → `get keywords` 用户说"原文/转写/录音文字" → `get transcription` 用户说"会议待办/听记待办" → `get todos` 用户说"改听记标题/重命名听记" → `update title` -用户说"发起听记/开始录音" → `record start` -用户说"暂停听记/暂停录音" → `record pause` -用户说"继续听记/恢复录音" → `record resume` -用户说"结束听记/结束录音" → `record stop` +用户说"改摘要/覆盖摘要/重写总结" → `update summary` +用户说"全文替换/批量改转写里的错字/专有名词搞错了" → `replace-text` +用户说"改发言人/把说话人1改成张三/认领发言人" → `speaker replace` +用户说"加热词/让后续识别更准/把某个专业术语教给系统" → `hot-word add` +用户说"生成思维导图/导图/脑图" → `mind-graph create`(异步),再 `mind-graph status` 轮询 +用户说"上传录音生成听记/把本地音视频变听记" → `upload create` → 本地 PUT → `upload complete`;放弃则 `upload cancel` 用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作 ## 核心工作流 ```bash -# 0. 发起听记(开始录音) -dws minutes record start --format json - # 1. 查看我的听记列表 — 提取 taskUuid dws minutes list mine --format json dws minutes list mine --max 10 --next-token --format json @@ -236,13 +311,28 @@ dws minutes get transcription --id --format json # 4. 提取待办事项 dws minutes get todos --id --format json -# 5. 修改标题 -dws minutes update title --id --title "新标题" --format json +# 5. 批量补齐多个 taskUuid 的详情 +dws minutes get batch --ids ,, --format json -# 6. 录音控制(基于 start 返回的 taskUuid) -dws minutes record pause --id --format json -dws minutes record resume --id --format json -dws minutes record stop --id --format json +# 6. 修改标题 / 覆盖摘要 +dws minutes update title --id --title "新标题" --format json +dws minutes update summary --id --content "修订后的摘要 Markdown" --format json + +# 7. 修正转写错字 / 发言人标签 / 加热词 +dws minutes replace-text --id --search "小钉" --replace "DingTalk" --format json +dws minutes speaker replace --id --from "说话人1" --to "张三" --format json +dws minutes hot-word add --words "钉钉,悟空,DWS" --format json + +# 8. 异步生成思维导图(create 拿 jobId,status 轮询) +dws minutes mind-graph create --id --format json +dws minutes mind-graph status --id --format json + +# 9. 从本地音视频上传生成听记(三步走) +dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘" --format json +# → 拿到 sessionId 和预签名 URL,调用方自行 HTTP PUT 把文件推上去 +dws minutes upload complete --session-id --format json +# 放弃上传 +dws minutes upload cancel --session-id --format json ``` ## 上下文传递表 @@ -253,18 +343,23 @@ dws minutes record stop --id --format json | `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token | | `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token | | `get batch` | 各听记 `taskUuid` | 进一步查询详情 | +| `upload create` | `sessionId`、预签名 URL | 本地 PUT 上传 + `upload complete` / `upload cancel` 的 --session-id | +| `upload complete` | 新听记 `taskUuid` | 后续 get/update/mind-graph 的 --id | +| `mind-graph create` | 异步 jobId(随 taskUuid 关联) | `mind-graph status` 的 --id 查进度与结果 | ## 注意事项 -- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参 -- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid` -- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`) +- `taskUuid` 是听记的唯一标识,所有 get/update/mind-graph/speaker/replace-text 操作均以此为入参 - 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询 -- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`) -- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选 +- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`);三者均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选 - `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条 -- `get summary` 返回 AI 生成的结构化 Markdown 摘要 +- `get info` 是单条查询;`get batch` 是批量补齐 ID 列表(`--ids` 逗号分隔),用在"有一串 taskUuid,想一次拿标题/时长/参与人"的场景 +- `get summary` 返回 AI 生成的结构化 Markdown 摘要;`update summary` 会整体覆盖原摘要,不做增量合并 - `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序 -- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid +- `replace-text` 是系统性纠错:命中即替换,整篇转写与摘要一起改,慎用模糊文本 +- `speaker replace` 只改"标签",不重跑说话人分离;`--target-uid` 可顺带把该说话人绑定到真实 userId +- `hot-word add` 作用于**个人**热词库,只对本人后续的听记识别生效 +- `mind-graph create` 是异步:调用后立即返回,需要用 `mind-graph status --id ` 轮询到 ready 才能拿到图 +- 上传流程严格三步:`upload create`(拿 sessionId + 预签名 URL)→ 客户端自己 HTTP PUT 文件 → `upload complete`(触发转写);中途放弃必须用 `upload cancel` 释放会话,不要只丢 sessionId ## 自动化脚本 diff --git a/skills/references/products/oa.md b/skills/references/products/oa.md new file mode 100644 index 00000000..ae544b11 --- /dev/null +++ b/skills/references/products/oa.md @@ -0,0 +1,199 @@ +# OA 审批 (oa) 命令参考 + +钉钉 OA 审批:待办审批列表 / 查看详情 / 同意 / 拒绝 / 撤销 / 流程记录 / 表单模板。 + +## 命令总览 + +### 查询待我处理的审批实例 +``` +Usage: + dws oa approval list-pending [flags] +Example: + dws oa approval list-pending + dws oa approval list-pending --page 1 --size 20 + dws oa approval list-pending --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" +Flags: + --page string 页码 (默认 1) + --size string 每页数量 (默认 10) + --start string 提交开始时间 ISO-8601 (可选) + --end string 提交结束时间 ISO-8601 (可选) +``` + +"待办收件箱"视图:列出当前用户仍需动作的审批 `processInstanceId`。注意返回的是**实例 ID**,真正驱动 approve/reject 还需要用 `tasks` 再换成 `taskId`。 + +### 查询我发起的审批实例 +``` +Usage: + dws oa approval list-initiated [flags] +Example: + dws oa approval list-initiated + dws oa approval list-initiated --process-code --max-results 20 + dws oa approval list-initiated --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --next-token +Flags: + --process-code string 按指定表单/流程模板过滤 (可选) + --start string 发起开始时间 ISO-8601 (可选) + --end string 发起结束时间 ISO-8601 (可选) + --max-results string 每页条数 (可选) + --next-token string 分页 token (首页留空) +``` + +查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。 + +### 查询可发起的审批表单模板 +``` +Usage: + dws oa approval list-forms [flags] +Example: + dws oa approval list-forms + dws oa approval list-forms --cursor 0 --size 20 +Flags: + --cursor string 分页游标,首次传 0 (默认 0) + --size string 每页大小 (可选) +``` + +列出当前用户被授权发起的审批表单(如"请假"、"报销"),返回 `processCode`,后续用于提交新的申请或按模板筛选历史实例。 + +### 获取审批实例详情 +``` +Usage: + dws oa approval detail [flags] +Example: + dws oa approval detail --instance-id +Flags: + --instance-id string 审批实例 processInstanceId (必填) +``` + +拉取单个实例的完整内容:表单字段、附件、当前状态、参与人。适合让 AI 读懂审批单再决定动作或生成摘要。 + +### 查询审批实例的操作记录 +``` +Usage: + dws oa approval records [flags] +Example: + dws oa approval records --instance-id +Flags: + --instance-id string 审批实例 processInstanceId (必填) +``` + +列出实例的流转历史(谁在什么时候同意/加签/转交/评论),用于复盘或解释"这单卡在哪"。 + +### 查询实例下待处理的任务 +``` +Usage: + dws oa approval tasks [flags] +Example: + dws oa approval tasks --instance-id +Flags: + --instance-id string 审批实例 processInstanceId (必填) +``` + +针对具体实例拿到当前用户名下的 `taskId`。approve / reject 要的是 `taskId`,不是 `processInstanceId`,这一步不能省。 + +### 同意审批 +``` +Usage: + dws oa approval approve [flags] +Example: + dws oa approval approve --instance-id --task-id + dws oa approval approve --instance-id --task-id --remark "同意,按流程推进" +Flags: + --instance-id string 审批实例 processInstanceId (必填) + --task-id string 待办任务 taskId (必填),来自 approval tasks + --remark string 审批意见 (可选) +``` + +以当前用户身份同意某个审批任务。`--task-id` 必须是当前用户的待办 taskId,否则 MCP 会拒绝。 + +### 拒绝审批 +``` +Usage: + dws oa approval reject [flags] +Example: + dws oa approval reject --instance-id --task-id --remark "预算不足,下月再议" +Flags: + --instance-id string 审批实例 processInstanceId (必填) + --task-id string 待办任务 taskId (必填),来自 approval tasks + --remark string 拒绝理由 (可选,但强烈建议填) +``` + +以当前用户身份驳回审批任务;拒绝后一般由 MCP 端决定流程是打回申请人还是整体终止。 + +### 撤回已发起的审批 +``` +Usage: + dws oa approval revoke [flags] +Example: + dws oa approval revoke --instance-id + dws oa approval revoke --instance-id --remark "金额填错,重新提交" --yes +Flags: + --instance-id string 审批实例 processInstanceId (必填) + --remark string 撤回原因 (可选) +``` + +只能撤销**自己发起且尚未终态**的实例。命令标了 `isSensitive`,AI 调用前建议与用户再确认一次,可配合全局 `--yes` 跳过确认。 + +## 意图判断 + +用户说"我要审的单/待我处理的审批/审批收件箱" → `list-pending` 拿 `processInstanceId` +用户说"这单我同意/批了它/通过" → `approval tasks` 换 `taskId` → `approval approve` +用户说"这单不行/驳回/拒绝" → `approval tasks` 换 `taskId` → `approval reject --remark "<理由>"` +用户说"这单详情/看看内容/这单在说什么" → `approval detail` +用户说"这单卡在谁那/谁还没审/流程走到哪" → `approval records` +用户说"我发起的审批/我的申请" → `list-initiated`(可用 `--process-code` 按模板筛) +用户说"能发起哪些审批/有哪些表单" → `list-forms` +用户说"撤回我的申请/我不提了" → `approval revoke`(仅限自己发起且未终态) + +关键区分: oa(钉钉 OA 审批流程) vs todo(个人待办) vs report(日志汇报) + +## 核心工作流 + +```bash +# 1. 看我有哪些待审的单 — 提取 processInstanceId +dws oa approval list-pending --page 1 --size 20 --format json + +# 2. 看单子内容(表单字段 / 附件 / 当前状态) +dws oa approval detail --instance-id --format json + +# 3. 想动作(同意/拒绝)前,先拿 taskId +dws oa approval tasks --instance-id --format json + +# 4. 同意 / 拒绝(taskId 来自步骤 3) +dws oa approval approve --instance-id --task-id --remark "同意" --format json +dws oa approval reject --instance-id --task-id --remark "预算不足" --format json + +# 5. 查看流程记录 — 谁何时做了什么 +dws oa approval records --instance-id --format json + +# 6. 查我发起的审批 / 按模板过滤 +dws oa approval list-initiated --format json +dws oa approval list-initiated --process-code --format json + +# 7. 能发起哪些表单 +dws oa approval list-forms --cursor 0 --size 20 --format json + +# 8. 撤回我发起的单 +dws oa approval revoke --instance-id --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 ` 换一次 +- `list-pending` 是"需要我动作"视图;同一个实例如果流转到下一节点就会从这里消失 +- `list-initiated` 是"我发起的"视图;和 `list-pending` 是两套语义,不要互相替代 +- `--start` / `--end` 使用 ISO-8601 格式,内部会自动转毫秒时间戳 +- `revoke` 只能撤销**当前用户发起且未完结**的实例;已流转结束、已作废、或不是自己发起的都会失败 +- `approve` / `reject` 的 `--remark` 可选但强烈建议填,尤其是 `reject`——审批流下游会把理由展示给申请人 +- 所有敏感动作(`approve` / `reject` / `revoke`)在 AI 调用时应与用户二次确认,可用全局 `--yes` 跳过确认 +- `list-forms` 返回的 `processCode` 是未来发起新审批、或筛选历史实例的钩子;目前本命令集只覆盖审批的"处理侧",发起新审批需走业务方自己的流程 diff --git a/skills/references/products/report.md b/skills/references/products/report.md index 72bff5b8..2a856db9 100644 --- a/skills/references/products/report.md +++ b/skills/references/products/report.md @@ -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 --format json -# 5. 查看日志统计(已读/未读) +# 5. 查看日志统计(浏览/点赞/评论聚合) dws report stats --report-id --format json # 6. 查看当前人创建的日志列表 diff --git a/skills/references/products/todo.md b/skills/references/products/todo.md index fa9c526b..fa7a0967 100644 --- a/skills/references/products/todo.md +++ b/skills/references/products/todo.md @@ -9,11 +9,15 @@ Usage: Example: dws todo task create --title "修复线上Bug" --executors , --priority 40 dws todo task create --title "提交报告" --executors --due "2026-03-20T10:00:00+08:00" + dws todo task create --title "每日站会" --executors \ + --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 --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` 可同时修改标题/优先级/截止时间/完成状态 diff --git a/test/unit/open_source_policy_test.go b/test/unit/open_source_policy_test.go index b6c3ab1d..e3f194d9 100644 --- a/test/unit/open_source_policy_test.go +++ b/test/unit/open_source_policy_test.go @@ -35,6 +35,8 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) { "REWIND_" + "MESSAGE_ID", "MergeWukong" + "MCPHeaders", "buildMode ==" + " \"real\"", + "wukong/" + "discovery", + "dingi8fo" + "prfi3jynjjlu", } var matches []string