From 7828089081fa415be612d8dc33273320080c30b8 Mon Sep 17 00:00:00 2001 From: Ari Date: Fri, 24 Apr 2026 00:01:22 +0800 Subject: [PATCH 1/4] feat: subcommand merging, schema pretty, IR overlays, v1.0.15 notes (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(compat): merge same-name subcommands under shared parent When multiple server entries attach to the same parent and their cli.command collides with a subcommand already in the parent tree, the incoming subcommand's children are merged recursively into the existing one instead of producing a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the duplicate `group` / `message` rows in `dws chat --help` that surfaced when bot capabilities were distributed across chat.group.members and chat.message subtrees. * feat(ir): carry FlagOverlay and ToolAnnotations into canonical catalog FlagOverlay mirrors the per-parameter CLI overlay (alias, transform, transformArgs, env default, default value, hidden) sourced from market.CLIToolOverride.Flags. ToolAnnotations mirrors MCP 2025+ tool annotations with nullable hints (destructive / read-only / idempotent / open-world) so absence means "unknown" rather than "false". BuildCatalog now propagates both through the canonical layer, with tests covering group metadata and flag-overlay passthrough. * feat(output): add -f pretty for schema-aware colored output dws schema now supports -f pretty alongside json / raw. Pretty mode partitions output by product / tool / parameter / enum and applies ANSI color so humans can browse the catalog without piping through jq. json / raw behaviour is unchanged. docs/reference.md gains a Schema Introspection section documenting dws schema and the new pretty format. * feat(skills): refresh 13-product references; add devdoc / drive / oa SKILL.md cli_version bumped to >=1.0.15; product index reordered to match the v1.0.15 command surface. New references: - devdoc.md: Open Platform documentation search - drive.md: DingTalk drive (promoted out of Coming soon) - oa.md: OA approval end-to-end flow Major rewrites for aitable (dashboard / chart share, import/export, attachment flow), chat (bot capabilities merged into chat.message and chat.group.members), calendar (event suggest / attachments), doc (comment subtree, file create, upload/download flow), and minutes (list mine/shared/all unified, record subcommands split). Minor alignment touch-ups for contact / report / todo flag names. * docs(release): sync v1.0.15 notes — 159 commands across 13 products README Key Services fully refreshed: chat 23, calendar 14, aitable 41, doc 21, minutes 19; drive promoted out of Coming soon; workbench and standalone bot rows removed; Quick Start expanded to 7 examples spanning doc / minutes / drive; Coming soon trimmed to mail / conference / aiapp / live / wiki. Adds docs/command-index.md, an auto-generated English listing of all 159 runtime commands with description and when-to-use guidance aimed at AI agents. Replaces the ad-hoc command-index.pre.* / command-index.full.* snapshots used during development. CHANGELOG 1.0.15 records the compat subcommand-merging feature, the new command index, and a flag-naming cleanup across chat / calendar / drive / minutes / contact / devdoc. --- CHANGELOG.md | 42 +++ README.md | 35 +- README_zh.md | 35 +- docs/command-index.md | 323 ++++++++++++++++++ docs/reference.md | 41 +++ internal/app/flags.go | 2 +- internal/cli/canonical.go | 131 +++++++- internal/cli/canonical_test.go | 199 +++++++++++ internal/compat/dynamic_commands.go | 39 ++- internal/compat/dynamic_commands_test.go | 209 ++++++++++++ internal/ir/catalog.go | 117 +++++++ internal/ir/catalog_test.go | 108 ++++++ internal/output/formatter.go | 11 +- internal/output/pretty.go | 302 +++++++++++++++++ internal/output/pretty_test.go | 221 +++++++++++++ skills/SKILL.md | 76 +++-- skills/references/products/aitable.md | 401 +++++++++++++++++++++-- skills/references/products/calendar.md | 46 ++- skills/references/products/chat.md | 138 ++------ skills/references/products/contact.md | 13 +- skills/references/products/devdoc.md | 48 +++ skills/references/products/doc.md | 129 +++++++- skills/references/products/drive.md | 177 ++++++++++ skills/references/products/minutes.md | 181 +++++++--- skills/references/products/oa.md | 199 +++++++++++ skills/references/products/report.md | 6 +- skills/references/products/todo.md | 13 +- 27 files changed, 2971 insertions(+), 271 deletions(-) create mode 100644 docs/command-index.md create mode 100644 internal/output/pretty.go create mode 100644 internal/output/pretty_test.go create mode 100644 skills/references/products/devdoc.md create mode 100644 skills/references/products/drive.md create mode 100644 skills/references/products/oa.md 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/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/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/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index ba0d57d8..9b9d5848 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -166,7 +166,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() @@ -389,6 +389,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. diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go index 68f06cc2..0e5a7e9b 100644 --- a/internal/compat/dynamic_commands_test.go +++ b/internal/compat/dynamic_commands_test.go @@ -16,6 +16,8 @@ package compat import ( "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" ) @@ -143,3 +145,210 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) { t.Fatalf("expected 2 top-level commands, got %d", len(cmds)) } } + +// 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/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/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/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` 可同时修改标题/优先级/截止时间/完成状态 From 97390829dd1e619ccfa5a480dea939fad3ba7b7f Mon Sep 17 00:00:00 2001 From: Ari Date: Fri, 24 Apr 2026 01:36:32 +0800 Subject: [PATCH 2/4] fix(compat,helpers): json_parse YAML fallback + send-by-bot product routing (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compat): accept YAML flow as a fallback for json_parse transform Users who run `dws aitable table create --fields [{fieldName: 标题, ...}]` hit a shell glob error (`zsh: bad pattern: [{fieldName:`) because the unquoted brackets are interpreted as a zsh pattern. Even when the shell is placated with outer quotes, the strict-JSON parser still rejected the natural form that drops quotes around keys and string values. transformJSONParse now tries strict JSON first (unchanged fast path) and, on failure, falls back to yaml.Unmarshal. YAML's flow syntax is a superset of JSON that accepts `{key: value}` without surrounding quotes, so a single set of outer quotes is all the user needs: dws aitable table create --fields '[{fieldName: 标题, type: text}]' Already-valid strict JSON is unaffected. The @file syntax (`--fields @path.json`) continues to work through the same transform. The terminal error message, if both parsers fail, now points users at the two working forms (quoted YAML flow or @file). * fix(helpers): route chat send-by-bot to bot product `chat message send-by-bot` previously stamped its invocation with CanonicalProduct="chat", routing it through the chat product's auth / server dispatch. The command is semantically a bot operation — switch it to "bot" so auth client selection and downstream routing match the actual MCP server. Extends chat_test.go with single-chat and group-chat routing assertions. --- internal/compat/transform.go | 31 +++++++- internal/compat/transform_test.go | 125 ++++++++++++++++++++++++++++++ internal/helpers/chat.go | 2 +- internal/helpers/chat_test.go | 51 ++++++++++++ 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 internal/compat/transform_test.go 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/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) + } + }) + } } From bbca5077927813bbd53a367438acb81a261c852e Mon Sep 17 00:00:00 2001 From: xianfeng wang Date: Fri, 24 Apr 2026 14:08:17 +0800 Subject: [PATCH 3/4] feat(discovery): MCP discovery service abstraction with positional params, examples, defaults, and schema v3 extensions (#156) * discovery * discovery * wk discovery * wk discovery * wk discovery * wk discovery * wk discovery * wk discovery * discovery example and default * position param * Fix test * dev auto login * fix test * fix conflict --- .gitignore | 1 + internal/app/auth_command_test.go | 4 +- internal/app/auth_refresh_retry.go | 83 + internal/app/direct_runtime.go | 26 +- internal/app/direct_runtime_override_test.go | 183 ++ internal/app/force_refresh.go | 62 + internal/app/legacy.go | 232 ++- internal/app/legacy_test.go | 111 ++ internal/app/root.go | 13 +- internal/app/root_cache_refresh_test.go | 93 + internal/app/runner.go | 35 +- internal/app/runner_test.go | 53 +- internal/app/testmain_test.go | 50 + internal/app/tool_caller_adapter.go | 14 + internal/auth/force_refresh.go | 41 + internal/auth/testmain_test.go | 40 + internal/auth/token_test.go | 5 +- internal/cli/loader.go | 23 + internal/compat/dynamic_commands.go | 513 +++++- internal/compat/dynamic_commands_test.go | 1593 ++++++++++++++++++ internal/compat/hint_redirect.go | 55 + internal/compat/output_format.go | 107 ++ internal/compat/registry.go | 485 +++++- internal/compat/registry_test.go | 65 + internal/compat/v3_helpers_test.go | 171 ++ internal/keychain/keychain.go | 7 + internal/keychain/keychain_darwin.go | 5 + internal/keychain/keychain_linux.go | 7 +- internal/market/registry.go | 160 +- pkg/authretry/authretry.go | 74 + pkg/cmdutil/leaf_merge.go | 100 ++ pkg/cmdutil/leaf_merge_test.go | 159 ++ pkg/cmdutil/provenance.go | 54 + pkg/edition/edition.go | 45 +- pkg/editiontest/contract.go | 44 + test/unit/open_source_policy_test.go | 2 + 36 files changed, 4566 insertions(+), 149 deletions(-) create mode 100644 internal/app/auth_refresh_retry.go create mode 100644 internal/app/direct_runtime_override_test.go create mode 100644 internal/app/force_refresh.go create mode 100644 internal/app/legacy_test.go create mode 100644 internal/app/testmain_test.go create mode 100644 internal/auth/force_refresh.go create mode 100644 internal/auth/testmain_test.go create mode 100644 internal/compat/hint_redirect.go create mode 100644 internal/compat/output_format.go create mode 100644 internal/compat/v3_helpers_test.go create mode 100644 pkg/authretry/authretry.go create mode 100644 pkg/cmdutil/leaf_merge.go create mode 100644 pkg/cmdutil/leaf_merge_test.go create mode 100644 pkg/cmdutil/provenance.go 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/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/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/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/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/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 9b9d5848..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) } } @@ -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) != "" { @@ -452,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 } @@ -475,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) @@ -509,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, ".") { @@ -520,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 } } @@ -542,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] @@ -561,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 { @@ -618,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 0e5a7e9b..1bd06621 100644 --- a/internal/compat/dynamic_commands_test.go +++ b/internal/compat/dynamic_commands_test.go @@ -14,6 +14,8 @@ package compat import ( + "context" + "strings" "testing" "github.com/spf13/cobra" @@ -22,6 +24,30 @@ import ( "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() @@ -113,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() @@ -146,6 +1420,325 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) { } } +// 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 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/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/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/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/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 From ad33a46883be6e7a89bccc36b16a10f097a6ef30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ybc=E2=9D=A4=EF=B8=8Fzyr?= <163094287+ybcstudy@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:11:16 +0800 Subject: [PATCH 4/4] fix: device flow defensively resets credentials before login (#155) (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device flow now always clears stale credential state and re-fetches clientID from MCP server, regardless of what previous login methods (OAuth scan, PAT, etc.) left in app.json or runtime globals. This replaces the previous Source-field approach with a simpler defensive reset that is future-proof against new login methods. Root cause: after OAuth login saved app.json without MCP source marker, subsequent --device login reused the stale clientID without setting clientIDFromMCP flag, causing exchangeCode() to use direct mode which requires clientSecret. Changes: - Add resetCredentialState() to DeviceFlowProvider - Login() always calls resetCredentialState() + FetchClientIDFromMCP() - Remove AppConfig.Source field (no longer needed) - Remove app_config_source_test.go (replaced by device_flow_reset_test.go) - Add 5 tests covering OAuth→device, legacy→device, direct→device scenarios Co-authored-by: 猷诺 --- internal/auth/credential_reset_test.go | 319 +++++++++++++++++++++++++ internal/auth/device_flow.go | 46 ++-- internal/auth/oauth_provider.go | 41 ++-- 3 files changed, 376 insertions(+), 30 deletions(-) create mode 100644 internal/auth/credential_reset_test.go 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/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.