Compare commits

...
8 Commits
Author SHA1 Message Date
Ari 97390829dd fix(compat,helpers): json_parse YAML fallback + send-by-bot product routing (#161)
* 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.
2026-04-24 01:36:32 +08:00
Ari 7828089081 feat: subcommand merging, schema pretty, IR overlays, v1.0.15 notes (#160)
* 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.
2026-04-24 00:01:22 +08:00
修雨 af000a8dfa docs(release): sync v1.0.13 notes for chat IM capability expansion (#153)
- CHANGELOG: backfill 1.0.13 entry covering user-identity send, read/
  search paths, topic replies, mentions, focused/unread/top/common
  conversations, org-wide group creation and bot lifecycle commands
  added in #148.
- README Key Services: Chat 10 -> 20, Bot 6 -> 7, total 141 -> 152
  across 14 products; mention `dws im` alias in the Chat row.
2026-04-22 21:44:35 +08:00
修雨 f19a3ccfa5 feat(skills): 同步 chat/bot skill 参考 — 补齐消息管理与机器人新能力 (#148)
对齐 dws-service-endpoints.json 新增的 16 个 group-chat toolOverrides
和 2 个 bot toolOverrides,参考 dws-wukong 实现补齐以下命令文档:

- message: send/list/list-all/list-topic-replies/list-by-sender/
           list-mentions/list-focused/list-unread-conversations/
           search/info/send-personal/list-top-conversations
- group: create-org/search-common
- bot: create/search-groups

同步更新命令总览、意图判断、工作流与上下文传递表。
2026-04-22 16:31:56 +08:00
xuan 89c5038446 fix(auth): restore device_code polling when flowId is absent (#145)
* fix(auth): fallback to device_code when flowId is missing

* chore(auth): remove dead helper and harden fallback tests
2026-04-22 15:13:40 +08:00
修雨 4f915e4e2c refactor(plugin): drop managed/ compat layer left by PR #126 (#135)
PR #126 removed the privileged managed-plugin mechanism but kept
LoadManaged, IsManaged, and ~/.dws/plugins/managed/ fallbacks so that
plugins installed by pre-#126 builds would keep loading. The window
for that migration was ~4 days (2026-04-15 → 2026-04-19), and the
original install path mostly failed anyway (issue #124 / GitHub Pages
HTML response), so there is no real user base to preserve.

Scope of deletion:
- plugin.Loader.LoadManaged, scanDir's isManaged param, loadPlugin's
  isManaged param. LoadAll now combines user + dev only.
- Plugin.IsManaged field. Converter/root.go no longer branch on it;
  stdio and HTTP descriptors both report source="plugin".
- ~/.dws/plugins/managed/ branches in ListInstalled, SetEnabled,
  RemovePlugin.
- config.PluginManagedDir constant.
- TestLoaderLoadManaged, TestRemoveLegacyManagedPlugin, and the
  "legacy managed" table case in TestRemovePluginPurgesSettings.

Kept on purpose:
- Manifest.Type public schema field ("managed"|"user") — it appears
  in existing plugin.json files and is only validated, never
  behaviorally consumed.

Users with an orphaned ~/.dws/plugins/managed/ directory can
`rm -rf ~/.dws/plugins/managed/` — the CLI no longer reads it.
2026-04-22 14:56:11 +08:00
Ari ec03b7cca3 docs(changelog): backfill 1.0.3 through 1.0.12 release notes (#146)
CHANGELOG.md had been stuck at 1.0.2; releases 1.0.3, 1.0.4, 1.0.6,
1.0.7, 1.0.8, 1.0.9, 1.0.11, and 1.0.12 are now documented in full.
Versions 1.0.5 and 1.0.10 were not shipped as git tags and are
intentionally skipped.

Highlights:
- 1.0.3: nested/array output filtering, schema table rendering, todo
  static command family
- 1.0.4: client credential keychain persistence for token refresh
- 1.0.6: error diagnostics overhaul (trace_id/server_code/verbosity
  levels), destructive-command confirmation, OAuth credential persistence
- 1.0.7: dws upgrade self-updater, Wukong edition overlay, auth check
  flipped to fail-closed
- 1.0.8: aitable static helper commands (20 commands)
- 1.0.9: plugin system launch, dws doctor/config, 3->5 stage command
  pipeline, edition hooks
- 1.0.11: plugin cold-start perf, PAT scope error UX, i18n fixes
- 1.0.12: doc + minutes skill references, aitable dashboard/chart/export
  sections, README catalog sync to 141 commands / 14 products
2026-04-22 12:19:22 +08:00
fantiuandgithub-actions[bot] e0544579d2 fix(auth): attribute CLI auth denial to channel before user scope (#144)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* fix(auth): attribute CLI auth denial to channel before user scope

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-22 12:05:30 +08:00
43 changed files with 4476 additions and 579 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.7%"><title>coverage: 48.7%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 49.8%"><title>coverage: 49.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">49.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">49.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+335
View File
@@ -4,6 +4,341 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.15] - 2026-04-23
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
### Added
- **`internal/compat` subcommand merging via `attachOrMerge`** — when two or more server entries attach to the same parent (e.g. `parent: "chat"`) and their `cli.command` collides with an existing subcommand in the parent's tree, the new subcommand's children are merged recursively into the existing one instead of creating a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the "double `group` / `message` rows in `dws chat --help`" symptom when bot capabilities are distributed across `chat.group.members` and `chat.message`.
- **`docs/command-index.md`** — a single, English, auto-generated listing of every runtime command the `dws` CLI exposes under the pre environment (159 total). Each entry carries a description and a "when to use" column aimed at AI agents. Replaces the earlier `command-index.pre.*` / `command-index.full.*` ad-hoc snapshots.
### Changed
- **README Key Services table** (`README.md` + `README_zh.md`) fully synced to the shipped command surface:
- `Chat`: 20 → **23** (bot capabilities merged in; new `list-all` / `list-focused` / `list-unread-conversations` / `conversation-info` exposed)
- `Calendar`: 13 → **14**
- `AI Tables`: 37 → **41** (chart / dashboard public-share config rows)
- `Doc`: 16 → **21** (comment subtree + `file create`)
- `Minutes`: 22 → **19** (single-tool `record`, `list query`, `list-by-keyword-range` pruned)
- New `Drive` row (6 commands) — promoted out of "Coming soon"
- `Workbench` row and standalone `Bot` row removed
- Total revised to **159 commands across 13 products**
- **Quick Start** expanded to 7 examples covering `doc`, `minutes`, `drive` in addition to `contact`, `calendar`, `todo`
- **Coming soon** trimmed to 5: `mail`, `conference`, `aiapp`, `live`, `wiki`
- **Reference & Docs** section now leads with a pointer to the new `docs/command-index.md`
- **Flag naming cleanup** — CLI flags across chat, calendar, drive, minutes, contact, and devdoc have been standardised so the names users type match the product-skill documentation. Notable flags:
- `dws contact user search` / `dws contact dept search` / `dws devdoc article search` now take `--query` (previously `--keyword`)
- `dws chat message list` / `dws chat message search` / `dws chat message list-mentions` / `dws chat conversation-info` / `dws chat message send` now take `--group` for the target conversation (previously `--id`) and `--open-dingtalk-id` (previously `--open-id`)
- `dws chat message list-by-sender` now takes `--sender-user-id` / `--sender-open-dingtalk-id` (previously `--user` / `--open-id`)
- `dws chat message list-topic-replies` now takes `--group` / `--topic-id` / `--limit` / `--time` (previously `--id` / `--topic` / `--size` / `--start`)
- `dws chat search-common` now takes `--match-mode` (previously `--mode`)
- `dws drive list` now takes `--max` / `--thumbnail` (previously `--max-results` / `--with-thumbnail`)
- `dws calendar event suggest` now takes `--users` / `--duration` / `--timezone` (previously `--attendee-user-ids` / `--duration-minutes` / `--time-zone`)
- `dws minutes list mine` / `dws minutes list shared` now take `--max` (previously `--max-results`) and gain `--query` / `--start` / `--end`
- `dws minutes list all` no longer exposes the legacy `--__scope__` internal alias
- **Flag coverage additions** — `dws calendar event create` / `update` gain `--attendees`, `--open-dingtalk-ids`, `--timezone`; `dws chat message send` gains file-message flags (`--dentry-id`, `--file-name`, `--file-size`, `--file-type`, `--media-id`, `--msg-type`, `--space-id`) plus `--open-dingtalk-id` / `--user`; `dws chat message list` gains `--open-dingtalk-id` / `--user`; `dws aitable table delete` gains `--reason`; `dws calendar participant add` gains `--optional`; `dws todo task create` gains `--recurrence`.
### Tests
- 3 new unit tests in `internal/compat/dynamic_commands_test.go`:
- `TestBuildDynamicCommands_ParentMergeSameName` — two servers with identical `command` + `parent` collapse into a single merged subcommand
- `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
- `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
## [1.0.13] - 2026-04-22
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
### Added
- **`dws im` alias** — `dws im` is now registered as an alias of `dws chat` for intent clarity
- **User-identity messaging** (`chat message send`) — send group or 1-on-1 messages as the current user
- Recipient selection is mutually exclusive: `--group <openConversationId>` / `--user <userId>` / `--open-dingtalk-id <openDingTalkId>`
- Markdown text via `--text` (or positional arg), optional `--title`
- Group-only: `--at-all` to @everyone, `--at-users` for per-member @mentions
- Image messages via `--media-id` (obtained from `dt_media_upload`)
- **Personal messages** (`chat message send-personal`) — sensitive personal-channel send (⚠️ destructive/dangerous op, requires confirmation)
- **Conversation read paths**:
- `chat message list` — pull group / 1-on-1 conversation messages
- `chat message list-all` — pull all conversations for the current user in a time range
- `chat message list-topic-replies` — pull group topic reply threads
- `chat message list-by-sender` — messages by a specific sender
- `chat message list-mentions` — messages where the current user was @-mentioned
- `chat message list-focused` — messages from focused / starred contacts
- `chat message list-unread-conversations` — unread conversation list
- `chat message search` — keyword search across conversations
- `chat message info` — conversation metadata
- `chat list-top-conversations` — pinned conversation list
- **Group creation & discovery**:
- `chat group create-org` — create an organization-wide group
- `chat search-common` — search groups shared with a nickname list (`--nicks`, `--match-mode AND|OR`, cursor-based pagination)
- **Bot lifecycle**:
- `chat bot create` — create an enterprise bot
- `chat bot search-groups` — search the groups a bot is present in
### Changed
- **`chat` skill reference** (`skills/references/products/chat.md`, #148) restructured into three sub-groups — `group` (9) / `message` (15) / `bot` (3) — with refreshed intent-routing table, workflow examples, and context-passing rules aligned with `dws-service-endpoints.json` (16 new group-chat tool overrides + 2 new bot tool overrides)
- **README Key Services** sync:
- `Chat` row: 10 → 20 commands; subcommand tags expanded to `message` `group` `search` `list-top-conversations`
- `Bot` row: 6 → 7 commands; subcommand tags expanded with `create` `search-groups`
- Total raised to **152 commands across 14 products**
## [1.0.12] - 2026-04-21
Product-surface expansion: first-class `doc` (DingTalk Docs) and `minutes` (AI Minutes) skill references, refreshed `aitable` guide aligned with the shipped binary (including dashboard / chart / export), and a README sync that brings the full command catalog to **141 commands across 14 products**.
### Added
- **`doc` skill reference** (`skills/references/products/doc.md`) — 16-command coverage of DingTalk Docs:
- Discovery: `search`, `list`, `info`, `read`
- Authoring: `create`, `update`, `folder create`
- Files: `upload`, `download`
- Block-level editing: block `query`, `insert`, `update`, `delete`
- Comments: `comment list`, `create`, `reply`
- URL → `doc_id` extraction rules and nodeId dual-format notes
- **`minutes` skill reference** (`skills/references/products/minutes.md`) — coverage of AI Minutes:
- Lists: personal / shared-with-me / all-accessible
- Content: basic info, AI summary, keywords, transcription, extracted todos, batch detail
- Editing: title update
- Recording control: start, pause, resume, stop
- **SKILL.md routing**:
- Product overview table rows for `doc` and `minutes`
- Intent decision tree routes — `钉钉文档/云文档/知识库/块级编辑/文档评论` → `doc`; `听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词` → `minutes`
- Danger-op table entries: `doc delete`, `doc block delete`
- `aitable` description completed with the `附件` (attachment) group
- **`aitable` skill enhancements**:
- `field create` single-field mode (`--name` / `--type` / `--config`) with examples
- `base get` URL → `baseId` quick-tip
- Dedicated "URL → baseId 提取" chapter
- "`--filters` 筛选语法排错与使用规范" chapter
- "相关产品" cross-link section pointing to `doc`
- **"复杂操作" chapter** (#141) — dashboard / chart workflow (with two-call sequencing and `chart share get` vs `dashboard share get` error semantics) and two-stage `export data` polling (`scope=all/table/view` parameter constraints)
- **README Key Services sync** (#140):
- New rows: `doc` (16 commands), `minutes` (22 commands — adds `hot-word`, `mind-graph`, `replace-text`, `speaker`, `upload` subgroups)
- `aitable` expanded from 20 → 37 commands; surfaces `chart`, `dashboard`, `export`, `import`, `view` subgroups
- Total command count updated from **86 → 141 across 14 products**
- "Coming soon" list drops `doc` and `minutes`
### Changed
- `aitable record query` docs rename `--keyword` → `--query` to match the shipped binary
- `aitable record query` docs clarify `--sort` direction semantics (avoids misuse of `order`)
- `aitable base list` guidance strengthened — "only for recent browsing; use `base search` for lookups"; intent decision prioritizes `base search` for base discovery
## [1.0.11] - 2026-04-20
Plugin subsystem hardening: faster cold startup, cleaner lifecycle, stricter isolation, and polished UX for PAT / i18n / error routing.
### Added
- `feat: supports claw-like products` — overlay path for Claw-style embedded editions
- `feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses`
- `feat(auth): improve login UX for terminal auth denial cases` — clearer messaging + retry affordance
- `feat: PAT scope error visualization and auto-retry with authorization polling` (#113)
- Human-readable error output (lark-cli style) with type/message/hint/authorization command
- JSON payload also available via `--format json`
- Auto-retry once the user completes scope authorization
### Changed
- `perf(plugin): serve plugin MCP tool list from disk cache on startup` — hot path skips Initialize+ListTools when snapshot exists
- `perf(plugin): parallelize all plugin discovery and tighten cold timeouts` — HTTP cold budget 4s → 700ms (auth) / 500ms (plain); stdio and HTTP fan out concurrently
- `perf(plugin): share cache.Store across discovery` — single `*cache.Store` above the fan-out instead of per-goroutine instances
- `refactor(plugin): remove default/managed plugin privileged mechanism` (#124) — third-party plugins install on an equal footing via `dws plugin install`
- `refactor(plugin): purge removed plugin settings instead of merely disabling` — `RemovePlugin` now deletes `EnabledPlugins` and `PluginConfigs` entries
### Fixed
- `fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable` (#119) — eliminates the 10s `dws --help` stall caused by compounding transport timeouts
- `fix(plugin): stop stdio child processes on exit and before removal` — no more orphaned plugin subprocesses
- `fix(pat): avoid shared PAT command state in root registration` (#129)
- `fix: -f json 模式下错误 JSON 从 stdout 改为输出到 stderr` (#133) — restores CI stderr-based failure assertions
- `fix(cli): localize plugin/help command strings via i18n` (#118, #134) — zh locale now shows consistent Chinese `--help`; wraps plugin module, help command, and OAuth client-id/secret flag descriptions
- `chore: remove workspace and bundled artifacts` (#127) — clean local-only repository leftovers
## [1.0.9] - 2026-04-16
Plugin system launch + execution-pipeline overhaul. This is the largest release since 1.0.0: third-party MCP servers become first-class commands, the command pipeline grows to five stages, and the edition overlay gains the hooks needed for embedded hosts.
### Added
#### Plugin system (new)
- `plugin` command family: `install`, `list`, `info`, `enable`, `disable`, `remove`, `create`, `dev`, `config set/get/list/unset`
- Plugin manifest parsing/validation, managed/user directory-based identity
- MCP server conversion and injection into the dynamic routing registry
- Pipeline hook adapter for shell-based hooks
- Stdio transport: subprocess lifecycle, `DWS_PLUGIN_ROOT` / `DWS_PLUGIN_DATA` variable expansion
- Stdio server tools automatically registered as CLI subcommands (e.g. `dws hello greet --name Peter`)
- Streamable-HTTP MCP tool discovery via `registerHTTPServer`
- Updater: managed plugin update check on CLI startup (10 s timeout, best-effort)
- `dws plugin create` scaffold (plugin.json, SKILL.md, hooks.json); `dws plugin dev` source-dir registration without copy
- `SyncSkills` — copies plugin skills to agent directories on startup
- **Auth Token Registry**: per-server HTTP headers declared in `plugin.json` for third-party MCP servers (e.g. Alibaba Cloud Bailian) independent from DingTalk OAuth
- **Persistent plugin config** (`dws plugin config ...`): values persisted to `~/.dws/settings.json`, auto-injected as env vars; `${KEY}` in `plugin.json` resolves without manual `export`
- **Build lifecycle**: `build` field compiles stdio servers to native binaries at install time
- **Command-name conflict protection**: reserved built-in names (`auth`, `plugin`, `cache`, …) and plugin-vs-plugin duplicate detection
- Parallel service discovery (`sync.WaitGroup`) — startup reduced from sequential `N*10s` to parallel `max(10s)`
#### Core commands & diagnostics
- `dws doctor` — one-stop environment/auth/network diagnostics
- `dws config list` — centralized view of scattered configuration
- Structured perf tracing (upgraded from debug tool to diagnostics output)
- `feat(skill): restore find/get for legacy skill market API` — `skill find`, `skill get`; `skill add` still uses aihub download
#### Edition / overlay hooks
- `edition.Hooks.SaveToken` / `LoadToken` / `DeleteToken` — delegate token persistence with keychain fallback
- `edition.Hooks.AuthClientID` / `AuthClientFromMCP` — overlay can override the OAuth client ID and route auth through MCP endpoints
- `edition.Hooks.AfterPersistentPreRun` — wire non-MCP clients (e.g. A2A gateway) after root setup
- `edition.Hooks.ClassifyToolResult` — custom MCP result classification before the default business-error detection
- Token marker file (`token.json`) for embedded hosts to detect auth state without keychain access
- `pkg/runtimetoken.ResolveAccessToken` mirroring MCP auth resolution; MCP identity headers exported via `pkg/cli` for auxiliary HTTP transports
- `ExitCoder` interface — edition-specific errors carry custom exit codes
- `RawStderrError` interface — errors that bypass CLI formatting and emit raw stderr (for desktop runtimes)
### Changed
- **Command execution pipeline: 3 → 5 stages** (`Register → PreParse → PostParse → PreRequest → PostResponse`)
- `feat(schema): return structured degraded errors instead of silent empty catalog` — new `CatalogDegraded` error with reasons `unauthenticated` / `market_unreachable` / `runtime_all_failed`; auth pre-check short-circuits doomed MCP connections
- `refactor(auth): unify auxiliary token resolution with MCP cached path` — shared `resolveAccessTokenFromDir`; overlays reuse the process-level token cache
- `feat(plugin): improve CLI overlay resolution and plugin install robustness`
- `plugin.json` `cli` field now accepts a file path (e.g. `"cli": "overlay.json"`) in addition to inline JSON
- `description` field on `CLIToolOverride` for static fallback when MCP `tools/list` is unavailable
- Windows install uses `cmd /C` instead of `sh -c` for build commands
### Fixed
- `fix(plugin): harden plugin system security boundaries`
- Reject `file://` / local paths in git URLs; allow only `https` / `ssh`
- Reject symlink entries during ZIP extraction (path-traversal defense)
- `build.output` must be a relative path within the plugin directory
- Reject absolute paths in stdio command declarations
- Block dangerous env var names (`PATH`, `LD_PRELOAD`, …) from plugin config injection
- `fix(plugin): schema flag params, HTTP tool discovery, and integration tests`
- `fix(plugin): skip min version check in dev mode`
## [1.0.8] - 2026-04-07
AITable command surface expansion, installer alignment with npm conventions, and execution-timeout hardening.
### Added
- **AITable static helper commands** (20 commands in total) replacing dynamic routing:
- `base`: `list`, `search`, `get`, `create`, `update`
- `table`: `get`, `create`, `update`
- `field`: `get`, `create`, `update`
- `record`: `query`, `create`, `update`
- `template`: `search`
- `attachment`: `upload`
- `feat(install): align skill dirs with npm and add OpenClaw` — skill install paths follow npm conventions; OpenClaw added to supported agents
- Label rendering optimization for AITable records (`to #73551688`)
- README: npm install method documented
- README: note that `dws upgrade` requires v1.0.7+
### Changed
- `perf: optimize command timeout handling, instrumentation, and diagnostics`
## [1.0.7] - 2026-04-02
Self-upgrade, edition overlay foundation, and fail-closed auth enforcement.
### Added
- **`dws upgrade`** — self-upgrade via GitHub Releases; atomic replace; cross-platform (macOS/Linux/Windows)
- `feat: edition layer for Wukong overlay` — build-time edition hook lets downstream overlays customize auth UX, config dir, static server list, visible products, and extra root commands
- `pkg/edition` defaults + `pkg/editiontest` contract tests
- `Makefile` target `edition-test`; CI job `edition-tests`
- Static server injection skips market discovery when configured
- Deduplicates top-level commands so overlay wins
- `hideNonDirectRuntimeCommands` respects edition `VisibleProducts`
- Gated `auth login` subcommand + hints for embedded editions
- Optional token auto-purge; edition `ConfigDir` override
- `dws version` — human-readable multi-line output plus JSON with edition, architecture, build, commit
- Tag reporting for case suites (`to #73551688`)
- `feat(auth): unify MCP retry constant and add retry to remaining endpoints`
### Changed
- `style(auth): redesign OAuth authorization pages UI`
### Fixed
- `fix(auth): switch CLI auth check from fail-open to fail-closed`
- When `/cli/cliAuthEnabled` is unreachable (network error/timeout/5xx), OAuth callback now routes to the permission request page instead of silently marking "enabled"
- Device Flow blocks login and asks the user to verify network connectivity
- `CheckCLIAuthEnabled` retries with backoff (3 attempts, 0s/1s/2s) to tolerate transient issues
## [1.0.6] - 2026-04-01
Error diagnostics overhaul, destructive-command confirmation, and credential auto-persistence.
### Added
- **Interactive confirmation for destructive dynamic commands** — prompts before delete/remove operations unless `--yes` is set
- **Enhanced error diagnostics**
- `ServerDiagnostics` struct extracts `trace_id`, `server_error_code`, `technical_detail`, `server_retryable` from MCP responses
- Pulls diagnostics from JSON-RPC `error.data`, tool call result content, and HTTP headers (`X-Trace-Id`, `X-Request-Id`, `x-dingtalk-trace-id`)
- Three verbosity levels for `PrintHuman`: Normal (trace ID + server code), Verbose (+ technical detail), Debug (+ RPC code / operation / reason)
- Local logging now includes sanitized request body, response body on error, retry attempts, and classification events
- `TruncateBody` / `SanitizeArguments` / `RedactHeaders` helpers with sensitive-key substring detection
- **Auth credential persistence**
- `feat(auth): enhance device flow with CLI auth check and admin guidance`
- `feat(auth): persist OAuth credentials for reliable token refresh`
- `feat(auth): persist client credentials and optimize keychain access` — auto-persist `--client-id` / `--client-secret`; keychain credential cache to avoid repeated reads; enhanced logout cleans `app.json` + keychain secrets + `token.json`
- `add report helper with flexible date parsing and defaults`
- `feat: to #73551688 支持消息通知`
- README: Official App mode (recommended, direct login without creating an app) + Custom App mode; admin guide for enabling CLI access
### Changed
- Getting Started simplified with inline login commands; whitelist references removed from the IMPORTANT banner
- Version bump documentation updated to v1.0.5 internal; co-creation group QR code refreshed
### Fixed
- `fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging`
- `resolveVerbosity` uses `cmd.Flags()` instead of `PersistentFlags()` so subcommands inherit `--verbose` / `--debug`
- `FileLogger` lazy-binds in `executeInvocation` (after `configureLogLevel` init)
- Business errors (HTTP 200 + `success=false`) now written to the file logger for offline diagnosis
- OAuth callback race condition (write response before sending code)
- `import path for errors package in skill_command.go`
## [1.0.4] - 2026-03-30
Token-refresh reliability and onboarding clarity.
### Added
- `feat(auth): persist client credentials for token refresh` — `--client-id` / `--client-secret` are stored for automatic refresh after expiration; client secret lives in the system Keychain with a file reference
- README onboarding flow rewrite with step-by-step first-time setup and more realistic examples
- Agent skill reference polish: clearer examples, updated intent routing patterns, expanded `simple.md` onboarding, cross-skill reference fixes
## [1.0.3] - 2026-03-29
Filtering power, schema rendering, and a native `todo` command family.
### Added
- **Nested / array-indexed output filtering**
- `--fields` now accepts dot-notation (e.g. `--fields response.content`) and array index access (e.g. `response.items[0]`)
- New field-path parser with recursive extraction logic
- **`schema` command enhancements**
- Table format output for human consumption
- Product-level endpoint loading in the CLI loader
- Schema-text rendering wired into the runner output pipeline
- **`todo` task helper family** — static `create` / `update` / `done` / `get` / `delete` with `preferLegacyLeaf` replacing dynamic commands
- MCP tool alignment: `create_personal_todo`, `update_todo_task`, `update_todo_done_status`, `query_todo_detail`, `delete_todo`
- ISO-8601 due-time parsing
- Hidden title aliases and delete confirmation
- Priority field on `todo` helper
- Expanded zh / en i18n coverage (fixes `en.json` spacing/wording issues)
- README restructured with collapsible feature sections
## [1.0.2] - 2026-03-29
Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing.
+21 -14
View File
@@ -185,12 +185,17 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
## Quick Start
```bash
dws contact user search --keyword "engineering" # search contacts
dws calendar event list # list calendar events
dws contact user search --query "engineering" # search contacts
dws calendar event list # list today's calendar events
dws doc search --query "quarterly" # search DingTalk Docs
dws minutes list mine # list AI meeting notes I created
dws drive list # list DingTalk drive files
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
dws todo task list --dry-run # preview without executing
```
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all 159 commands with descriptions and when-to-use guidance.
## Using with Agents
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
@@ -350,26 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
| 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 |
> 141 commands across 14 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> **159 commands across 13 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
<details>
<summary>Coming soon</summary>
`mail` (email) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
`mail` (email) · `conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
</details>
@@ -418,6 +424,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Reference & Docs
- [Command Index](./docs/command-index.md) — every runtime command (159 total) with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Changelog](./CHANGELOG.md) — release history and migration notes
+22 -13
View File
@@ -185,12 +185,17 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
## 快速开始
```bash
dws contact user search --keyword "悟空" # 搜索联系人
dws calendar event list # 查看日历日程
dws contact user search --query "悟空" # 搜索联系人
dws calendar event list # 查看今天的日程
dws doc search --query "季度" # 搜索钉钉文档
dws minutes list mine # 列出我创建的 AI 听记
dws drive list # 列出钉盘文件
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
dws todo task list --dry-run # 预览操作但不执行
```
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部 159 条命令,带描述和使用场景。
## 在 Agent 中使用
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
@@ -350,24 +355,27 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
> **13 个产品,159 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
<details>
<summary>即将推出</summary>
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
`mail`(邮箱)· `conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
</details>
@@ -418,6 +426,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 参考与文档
- [命令索引](./docs/command-index.md) — 159 条运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
+323
View File
@@ -0,0 +1,323 @@
# dws Command Index
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 159
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
## Global flags
Every command inherits these flags (documented here once, not repeated per command):
| Flag | Purpose |
|---|---|
| `--client-id` | Override OAuth client ID (DingTalk AppKey) |
| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) |
| `--debug` | Enable debug logging |
| `--dry-run` | Preview the request without executing |
| `--fields` | Comma-separated output field projection |
| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) |
| `--jq` | jq expression applied to JSON output |
| `--mock` | Return mock data (developer aid) |
| `-o, --output` | Write output to a file |
| `--timeout` | HTTP request timeout in seconds (default 30) |
| `--token` | Override the configured API token |
| `-v, --verbose` | Verbose logging |
| `-y, --yes` | Skip confirmation prompts (AI-agent mode) |
## Contents
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
## `dws aitable` — AI Tables
_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._
**41 commands**
| Command | Description | When to use |
|---|---|---|
| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. |
| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. |
| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. |
| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. |
| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. |
| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. |
| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. |
| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. |
| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. |
| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. |
| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. |
| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. |
| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. |
| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. |
| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. |
| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. |
| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. |
| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. |
| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. |
| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. |
| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. |
| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. |
| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. |
| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. |
| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. |
| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. |
| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. |
| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. |
| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. |
| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. |
| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. |
| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. |
| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. |
| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. |
| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. |
| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. |
| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. |
| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. |
| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. |
| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. |
| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. |
## `dws attendance` — Attendance
_Attendance check-in records, shifts, and aggregate statistics._
**4 commands**
| Command | Description | When to use |
|---|---|---|
| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. |
| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. |
| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. |
| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. |
## `dws calendar` — Calendar
_Calendar events, participants, meeting rooms, and busy-status queries._
**14 commands**
| Command | Description | When to use |
|---|---|---|
| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. |
| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. |
| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. |
| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. |
| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. |
| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. |
| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. |
| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. |
| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. |
| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. |
| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. |
| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. |
| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. |
| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. |
## `dws chat` — Group Chat / IM
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. |
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. |
| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. |
| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. |
| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. |
| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. |
| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. |
| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. |
| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. |
| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. |
## `dws contact` — Contact Directory
_Users, departments, and directory lookups._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
## `dws devdoc` — Open Platform Docs
_Search the DingTalk Open Platform documentation._
**1 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
## `dws ding` — DING Messages
_Send and recall DING messages (priority notifications)._
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. |
| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. |
## `dws doc` — DingTalk Doc
_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._
**21 commands**
| Command | Description | When to use |
|---|---|---|
| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. |
| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. |
| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. |
| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. |
| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. |
| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. |
| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. |
| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. |
| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. |
| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. |
| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. |
| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. |
| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. |
| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. |
| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. |
| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. |
| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. |
| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. |
| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. |
| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. |
| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. |
## `dws drive` — DingTalk Drive
_DingTalk Drive file and folder management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. |
| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. |
| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. |
| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. |
| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. |
| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. |
## `dws minutes` — AI Minutes
_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._
**19 commands**
| Command | Description | When to use |
|---|---|---|
| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. |
| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. |
| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. |
| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. |
| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. |
| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. |
| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. |
| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. |
| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. |
| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. |
| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. |
| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. |
| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. |
| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. |
| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. |
| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. |
| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. |
| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. |
| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. |
## `dws oa` — OA Approval
_OA approval workflows: list, approve, reject, revoke, records._
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. |
| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. |
| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. |
| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. |
## `dws report` — Reports
_DingTalk Report feature: templates, entries, and statistics._
**7 commands**
| Command | Description | When to use |
|---|---|---|
| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. |
| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. |
| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). |
| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. |
| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. |
| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. |
| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. |
## `dws todo` — Todo Tasks
_Personal todo task management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. |
| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. |
| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. |
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
+41
View File
@@ -32,6 +32,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
```
## Dry Run / 试运行
@@ -46,6 +47,46 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
dws contact user search --keyword "Alice" -o result.json
```
## Schema Introspection / Schema 查询
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
### 路径写法
```bash
dws schema # 列出所有产品 + 工具名
dws schema ding.send_ding_message # canonical: product.rpc_name
dws schema ding.message.send # CLI 点路径: product.group.cli_name
dws schema "ding message send" # CLI 空格路径(同上)
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
```
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
### 单工具输出字段
| 字段 | 说明 |
|------|------|
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
| `group` | CLI 父级 group 路径(dot-separated) |
| `title` / `description` | 工具名/说明(overlay 优先) |
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
### 筛选输出
```bash
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
```
## Shell Completion / 自动补全
```bash
+1 -1
View File
@@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
-21
View File
@@ -284,27 +284,6 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
return runner.Run(ctx, invocation)
}
// loadMCPClientIDIfNeeded ensures we have a client ID for device flow.
// Priority: in-memory runtime value → DWS_CLIENT_ID env → MCP remote fetch.
func loadMCPClientIDIfNeeded(ctx context.Context, configDir string) string {
clientID := authpkg.ClientID()
if clientID != "" {
return clientID
}
// Fallback: read from environment variable (set by previous PAT auth or caller).
if envID := os.Getenv("DWS_CLIENT_ID"); envID != "" {
authpkg.SetClientIDFromMCP(envID)
return envID
}
// Last resort: fetch from MCP server.
mcpClientID, err := authpkg.FetchClientIDFromMCP(ctx)
if err == nil && mcpClientID != "" {
authpkg.SetClientIDFromMCP(mcpClientID)
return mcpClientID
}
return ""
}
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
const (
+4 -15
View File
@@ -1091,18 +1091,13 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
}
}
// 1. Load plugins from the legacy managed/ directory (backward compat
// for plugins installed by older CLI builds).
managedPlugins := pluginLoader.LoadManaged()
// 2. Load user plugins (per settings.json)
// 1. Load user plugins (per settings.json)
userPlugins := pluginLoader.LoadUser()
// 3. Load dev plugins (registered via `dws plugin dev`)
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := pluginLoader.LoadDev()
allPlugins := append(managedPlugins, userPlugins...)
allPlugins = append(allPlugins, devPlugins...)
allPlugins := append(userPlugins, devPlugins...)
// 3. Discover tools from streamable-http servers and build CLI commands.
// Third-party servers with auth headers are discovered in parallel
@@ -1213,7 +1208,6 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
if len(allPlugins) > 0 {
slog.Debug("plugins loaded",
"managed", len(managedPlugins),
"user", len(userPlugins),
"dev", len(devPlugins),
)
@@ -1529,17 +1523,12 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
// Construct virtual endpoint and server descriptor.
endpoint := StdioEndpoint(p.Manifest.Name, sc.Key)
source := "plugin"
if p.IsManaged {
source = "plugin-managed"
}
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: endpoint,
Source: source,
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
@@ -0,0 +1,144 @@
// 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 "testing"
func TestClassifyDenialReason(t *testing.T) {
cases := []struct {
name string
status *CLIAuthStatus
currentChannel string
want string
}{
{
name: "error CHANNEL_REQUIRED",
status: &CLIAuthStatus{
ErrorCode: "CHANNEL_REQUIRED",
},
want: "channel_required",
},
{
name: "error NO_AUTH",
status: &CLIAuthStatus{
ErrorCode: "NO_AUTH",
},
want: "no_auth",
},
{
name: "success false or nil result → unknown",
status: &CLIAuthStatus{
Success: false,
},
want: "unknown",
},
{
name: "cliAuthEnabled true → no denial",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: true},
},
want: "",
},
{
name: "userScope forbidden wins over channel",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "forbidden",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "channel-b",
want: "user_forbidden",
},
{
// Real-world case reported: user is in allowedUsers but the current
// DWS_CHANNEL is not in allowedChannels. Reason must be channel,
// NOT user.
name: "user allowed but channel not in allowedChannels → channel_not_allowed",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
AllowedUsers: []string{"014566033934857460"},
ChannelScope: "specified",
AllowedChannels: []string{"2a4a658e467998befb7fa333c19ba2b3a3bacfa4"},
},
},
currentChannel: "different-channel",
want: "channel_not_allowed",
},
{
name: "channelScope specified but current channel empty → channel_required",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "",
want: "channel_required",
},
{
name: "channel matches allowedChannels → fall back to user denial",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "channel-a",
want: "user_not_allowed",
},
{
name: "only userScope=specified, no channel restriction → user_not_allowed",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
},
},
currentChannel: "",
want: "user_not_allowed",
},
{
name: "no user or channel restriction → cli_not_enabled",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: false},
},
want: "cli_not_enabled",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyDenialReason(tc.status, tc.currentChannel)
if got != tc.want {
t.Fatalf("classifyDenialReason() = %q, want %q", got, tc.want)
}
})
}
}
+63 -11
View File
@@ -185,11 +185,6 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
if err != nil {
return nil, err
}
if tokenResult == nil {
// FlowID was empty — no polling happened; authorization URL was already
// printed, so the user can handle it manually.
return nil, nil
}
_, _ = fmt.Fprintln(p.output(), "")
dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0)
@@ -377,15 +372,15 @@ func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string
}
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
// No FlowID from server — cannot poll status; return immediately so the
// user can still see the authorization URL printed earlier and handle it
// manually (same pattern as pat_auth_retry.go L451).
if auth.FlowID == "" {
dfPrintDim(p.output(), i18n.T(" 服务端未返回 flowId,跳过轮询,请在浏览器中手动完成授权后重试"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, nil
// Keep the pre-flowId device-code polling path for regular device flow
// login responses that do not include terminal polling metadata.
return p.waitForAuthorizationByDeviceCode(ctx, auth)
}
return p.waitForAuthorizationByFlowID(ctx, auth)
}
func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
startTime := time.Now()
interval := time.Duration(auth.Interval) * time.Second
deadline := time.Duration(auth.ExpiresIn) * time.Second
@@ -435,6 +430,63 @@ func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *Dev
}
}
func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
startTime := time.Now()
interval := time.Duration(auth.Interval) * time.Second
deadline := time.Duration(auth.ExpiresIn) * time.Second
pollCount := 0
for {
elapsed := time.Since(startTime)
if elapsed >= maxPollTotalWait || elapsed >= deadline {
_, _ = fmt.Fprintln(p.output(), "")
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
resp, err := p.pollDeviceToken(ctx, auth.DeviceCode)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
p.logger.Debug("poll error", "error", err)
}
continue
}
if resp.Error == "" {
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
return resp, nil
}
switch resp.Error {
case "authorization_pending":
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
case "slow_down":
interval += 5 * time.Second
if interval > maxPollInterval*time.Second {
interval = maxPollInterval * time.Second
}
dfPrintPollResult(p.output(), "slow_down", fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), int(interval.Seconds())))
case "access_denied":
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
case "expired_token":
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("设备授权码已过期"))
default:
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知错误: %s"), resp.Error))
}
}
}
func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(params.Encode()))
if err != nil {
+156
View File
@@ -14,8 +14,11 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
@@ -24,6 +27,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
)
func newDeviceFlowTestLogger() *slog.Logger {
@@ -138,6 +143,55 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
}
}
func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm() error = %v", err)
}
if got := r.FormValue("device_code"); got != "legacy-device-code" {
t.Fatalf("device_code = %q, want legacy-device-code", got)
}
if calls.Add(1) <= 2 {
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
return
}
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "legacy-auth-code"}, "", "")
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
var output bytes.Buffer
provider.Output = &output
provider.SetBaseURL(server.URL)
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("waitForAuthorization() error = %v", err)
}
if resp.AuthCode != "legacy-auth-code" {
t.Fatalf("auth code = %q, want legacy-auth-code", resp.AuthCode)
}
if calls.Load() != 3 {
t.Fatalf("poll calls = %d, want 3", calls.Load())
}
if !strings.Contains(output.String(), i18n.T("等待用户授权...")) {
t.Fatalf("expected device-code path to emit pending output, got %q", output.String())
}
if !strings.Contains(output.String(), i18n.T("授权成功!")) {
t.Fatalf("expected device-code path to emit success output, got %q", output.String())
}
}
func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
t.Parallel()
@@ -166,3 +220,105 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
}
}
func TestWaitForAuthorizationByDeviceCodeHonorsContextCancellation(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.Output = io.Discard
provider.SetBaseURL(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 60,
Interval: 1,
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("waitForAuthorization() error = %v, want context deadline exceeded", err)
}
}
func TestWaitForAuthorizationByDeviceCodeErrorStates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
timeout time.Duration
responses []DeviceTokenResponse
wantErr string
wantErrIs error
wantOutput string
}{
{
name: "slow_down_then_context_cancelled",
timeout: 1500 * time.Millisecond,
responses: []DeviceTokenResponse{{Error: "slow_down"}},
wantErrIs: context.DeadlineExceeded,
wantOutput: fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), 6),
},
{
name: "access_denied",
timeout: 5 * time.Second,
responses: []DeviceTokenResponse{{Error: "access_denied"}},
wantErr: i18n.T("用户拒绝了授权请求"),
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
},
{
name: "expired_token",
timeout: 5 * time.Second,
responses: []DeviceTokenResponse{{Error: "expired_token"}},
wantErr: i18n.T("设备授权码已过期"),
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := int(calls.Add(1)) - 1
if idx >= len(tt.responses) {
idx = len(tt.responses) - 1
}
writeServiceResult(w, true, tt.responses[idx], "", "")
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
var output bytes.Buffer
provider.Output = &output
provider.SetBaseURL(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 60,
Interval: 1,
})
if tt.wantErrIs != nil {
if !errors.Is(err, tt.wantErrIs) {
t.Fatalf("waitForAuthorization() error = %v, want %v", err, tt.wantErrIs)
}
} else if err == nil || err.Error() != tt.wantErr {
t.Fatalf("waitForAuthorization() error = %v, want %q", err, tt.wantErr)
}
if !strings.Contains(output.String(), tt.wantOutput) {
t.Fatalf("expected output to contain %q, got %q", tt.wantOutput, output.String())
}
})
}
}
+32 -12
View File
@@ -22,6 +22,7 @@ import (
"net/http"
"net/url"
"os"
"slices"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -1129,15 +1130,26 @@ type CLIAuthStatus struct {
// The server computes cliAuthEnabled by considering the org switch, userScope,
// and channelScope together; the CLI uses it as-is.
type CLIAuthResult struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden"
AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified"
ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified"
AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified"
CLIAuthEnabled bool `json:"cliAuthEnabled"`
UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden"
AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified"
ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified"
AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified"
ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured
}
// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable
// denial reason string. Returns "" when access is granted.
//
// Priority rationale:
// 1. Explicit org-wide ban (userScope=forbidden) always wins.
// 2. Channel scope is evaluated BEFORE user scope because the CLI has
// authoritative knowledge of DWS_CHANNEL and can verify membership against
// allowedChannels. This avoids falsely blaming the user when the real
// denial cause is a channel mismatch (e.g. user is in allowedUsers but the
// current channel is not in allowedChannels).
// 3. Only when the channel is unrestricted or matches do we attribute the
// denial to the user scope.
func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
if status.ErrorCode == "CHANNEL_REQUIRED" {
return "channel_required"
@@ -1148,18 +1160,26 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
if status.Result == nil || !status.Success {
return "unknown"
}
if status.Result.CLIAuthEnabled {
r := status.Result
if r.CLIAuthEnabled {
return ""
}
// cliAuthEnabled=false — infer reason from auxiliary fields (same priority as server)
if status.Result.UserScope == "forbidden" {
if r.UserScope == "forbidden" {
return "user_forbidden"
}
if status.Result.UserScope == "specified" {
return "user_not_allowed"
if r.ChannelScope == "specified" {
if currentChannel == "" {
return "channel_required"
}
if !slices.Contains(r.AllowedChannels, currentChannel) {
return "channel_not_allowed"
}
}
if status.Result.ChannelScope == "specified" && currentChannel != "" {
return "channel_not_allowed"
if r.UserScope == "specified" {
return "user_not_allowed"
}
return "cli_not_enabled"
}
+113 -18
View File
@@ -104,22 +104,38 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
}
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
return &cobra.Command{
Use: "schema [product.tool]",
cmd := &cobra.Command{
Use: "schema [path]",
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
不带参数时列出所有产品及其工具数量;带 product.tool 路径时
输出该工具的完整输入 Schema(JSON Schema 格式)。
不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
输入 Schema(JSON Schema 格式)、输出 Schema、MCP 注解和 CLI
层的 flag overlay(alias/transform/env_default)。
路径支持三种写法:
product.rpc_name 规范路径 (e.g. ding.send_ding_message)
product.group.cli_name CLI 点路径 (e.g. ding.message.send)
"product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send")
示例:
dws schema # 列出所有产品
dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema
dws schema --fields id,tools # 只显示 id 和 tools 字段
dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`,
dws schema # 列出所有产品
dws schema ding.send_ding_message # 规范路径
dws schema "ding message send" # CLI 路径(空格)
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
cliPath, _ := cmd.Flags().GetString("cli-path")
cliPath = strings.TrimSpace(cliPath)
if cliPath != "" {
if len(args) > 0 {
return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive")
}
args = []string{cliPath}
}
catalog, err := loader.Load(cmd.Context())
if err != nil {
var degraded *CatalogDegraded
@@ -158,6 +174,8 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
)
},
}
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
return cmd
}
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
@@ -664,7 +682,7 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
}, nil
}
product, tool, ok := catalog.FindTool(args[0])
product, tool, ok := resolveSchemaPath(catalog, args[0])
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
}
@@ -676,23 +694,100 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
}, nil
}
// compactTool returns a lean representation of a tool for schema
// output, keeping only the fields that AI agents and developers
// need: name, description, parameters, and sensitivity flag.
func compactTool(t ir.ToolDescriptor) map[string]any {
tool := map[string]any{
"name": t.RPCName,
"title": t.Title,
"description": t.Description,
"sensitive": t.Sensitive,
// resolveSchemaPath accepts three input forms and maps to (product, tool):
// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message")
// - "product.cli_name" (single-level CLI path, e.g. "doc.create")
// - CLI path with group ("ding message send" or "ding.message.send";
// also accepts "/" and multiple whitespace between tokens)
//
// Canonical form is tried first so existing callers and scripts keep
// working; only when that fails does the CLI-path resolver run.
func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
if product, tool, ok := catalog.FindTool(raw); ok {
return product, tool, true
}
tokens := splitSchemaPathTokens(raw)
if len(tokens) < 2 {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
productID := tokens[0]
leaf := tokens[len(tokens)-1]
groupPath := strings.Join(tokens[1:len(tokens)-1], ".")
product, ok := catalog.FindProduct(productID)
if !ok {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
for _, tool := range product.Tools {
if tool.CLIName != leaf {
continue
}
if strings.TrimSpace(tool.Group) != groupPath {
continue
}
return product, tool, true
}
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
// whitespace, returning only non-empty tokens. "ding message send",
// "ding.message.send", and "ding/message/send" all yield the same
// three tokens.
func splitSchemaPathTokens(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '.' || r == '/' || r == ' ' || r == '\t'
})
out := fields[:0]
for _, f := range fields {
if s := strings.TrimSpace(f); s != "" {
out = append(out, s)
}
}
return out
}
// compactTool returns a lean representation of a tool for schema
// output, keeping the fields AI agents and scripts need: RPC + CLI
// identity, input/output schema, sensitivity, MCP annotations, and the
// CLI flag overlay (alias/transform/envDefault/default) that shapes
// how raw MCP parameters appear on the command line.
func compactTool(t ir.ToolDescriptor) map[string]any {
tool := map[string]any{
"name": t.RPCName,
"cli_name": t.CLIName,
"canonical_path": t.CanonicalPath,
"title": t.Title,
"description": t.Description,
"sensitive": t.Sensitive,
}
if strings.TrimSpace(t.Group) != "" {
tool["group"] = t.Group
}
if props, ok := t.InputSchema["properties"]; ok {
tool["parameters"] = props
}
if req := requiredFields(t.InputSchema); len(req) > 0 {
tool["required"] = req
}
if len(t.OutputSchema) > 0 {
tool["output_schema"] = t.OutputSchema
}
if t.Annotations != nil {
tool["annotations"] = t.Annotations
}
if len(t.FlagOverlay) > 0 {
tool["flag_overlay"] = t.FlagOverlay
}
return tool
}
+199
View File
@@ -121,6 +121,205 @@ func TestSchemaPayloadFindsTool(t *testing.T) {
}
}
func TestCompactToolEmitsExtendedFields(t *testing.T) {
t.Parallel()
destructive := true
tool := ir.ToolDescriptor{
RPCName: "send_ding_message",
CLIName: "send",
Group: "message",
CanonicalPath: "ding.send_ding_message",
Title: "发送DING消息",
Description: "desc",
Sensitive: true,
InputSchema: map[string]any{
"type": "object",
"required": []any{"robotCode"},
"properties": map[string]any{
"robotCode": map[string]any{"type": "string"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"openDingId": map[string]any{"type": "string"},
},
},
Annotations: &ir.ToolAnnotations{DestructiveHint: &destructive},
FlagOverlay: map[string]ir.FlagOverlay{
"receiverUserIdList": {Alias: "users", Transform: "csv_to_array"},
},
}
out := compactTool(tool)
if out["name"] != "send_ding_message" {
t.Errorf("name = %v", out["name"])
}
if out["cli_name"] != "send" {
t.Errorf("cli_name = %v", out["cli_name"])
}
if out["canonical_path"] != "ding.send_ding_message" {
t.Errorf("canonical_path = %v", out["canonical_path"])
}
if out["group"] != "message" {
t.Errorf("group = %v", out["group"])
}
if _, ok := out["output_schema"]; !ok {
t.Errorf("output_schema missing, keys = %v", keysOf(out))
}
if _, ok := out["annotations"]; !ok {
t.Errorf("annotations missing, keys = %v", keysOf(out))
}
overlay, ok := out["flag_overlay"].(map[string]ir.FlagOverlay)
if !ok {
t.Fatalf("flag_overlay type = %T", out["flag_overlay"])
}
if overlay["receiverUserIdList"].Alias != "users" {
t.Errorf("overlay alias = %q", overlay["receiverUserIdList"].Alias)
}
}
func TestCompactToolOmitsEmptyExtras(t *testing.T) {
t.Parallel()
tool := ir.ToolDescriptor{
RPCName: "list_documents",
CLIName: "list",
CanonicalPath: "doc.list_documents",
InputSchema: map[string]any{"type": "object"},
}
out := compactTool(tool)
for _, key := range []string{"output_schema", "annotations", "flag_overlay", "group"} {
if _, has := out[key]; has {
t.Errorf("key %q should be omitted when empty, got %#v", key, out[key])
}
}
}
func TestSchemaPayloadResolvesCLIPath(t *testing.T) {
t.Parallel()
catalog := ir.Catalog{
Products: []ir.CanonicalProduct{
{
ID: "ding",
Tools: []ir.ToolDescriptor{
{
RPCName: "send_ding_message",
CLIName: "send",
Group: "message",
CanonicalPath: "ding.send_ding_message",
InputSchema: map[string]any{"type": "object"},
},
{
RPCName: "recall_ding_message",
CLIName: "recall",
Group: "message",
CanonicalPath: "ding.recall_ding_message",
InputSchema: map[string]any{"type": "object"},
},
},
},
},
}
cases := []struct {
name string
input string
wantRPC string
wantErr bool
}{
{"canonical rpc path", "ding.send_ding_message", "send_ding_message", false},
{"dotted cli path", "ding.message.send", "send_ding_message", false},
{"space cli path", "ding message send", "send_ding_message", false},
{"slash cli path", "ding/message/recall", "recall_ding_message", false},
{"unknown leaf", "ding message nope", "", true},
{"unknown group", "ding random send", "", true},
{"unknown product", "nope send", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload, err := schemaPayload(catalog, []string{tc.input})
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
tool, ok := payload["tool"].(map[string]any)
if !ok {
t.Fatalf("payload tool missing, got %#v", payload)
}
if tool["name"] != tc.wantRPC {
t.Errorf("tool name = %v, want %s", tool["name"], tc.wantRPC)
}
})
}
}
func TestSchemaCommandCLIPathFlag(t *testing.T) {
t.Parallel()
loader := StaticLoader{Catalog: ir.Catalog{
Products: []ir.CanonicalProduct{{
ID: "ding",
Tools: []ir.ToolDescriptor{{
RPCName: "send_ding_message",
CLIName: "send",
Group: "message",
CanonicalPath: "ding.send_ding_message",
InputSchema: map[string]any{"type": "object"},
}},
}},
}}
t.Run("resolves via --cli-path", func(t *testing.T) {
cmd := NewSchemaCommand(loader)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--cli-path", "ding message send"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
tool, ok := payload["tool"].(map[string]any)
if !ok {
t.Fatalf("tool missing: %#v", payload)
}
if tool["name"] != "send_ding_message" {
t.Errorf("tool name = %v", tool["name"])
}
})
t.Run("rejects positional + flag collision", func(t *testing.T) {
cmd := NewSchemaCommand(loader)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--cli-path", "ding message send", "ding.send_ding_message"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected mutual-exclusion error, got nil")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("err = %v, want mutual-exclusion message", err)
}
})
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestNewMCPCommandReturnsLoaderErrorForInvocations(t *testing.T) {
t.Parallel()
+38 -1
View File
@@ -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.
+209
View File
@@ -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)
}
}
+28 -3
View File
@@ -20,6 +20,8 @@ import (
"strings"
"time"
"gopkg.in/yaml.v3"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -110,6 +112,18 @@ func transformCSVToArray(value any) (any, error) {
return result, nil
}
// transformJSONParse parses a CLI string into a structured value so callers can
// pass complex payloads (JSON arrays/objects) through a single flag.
//
// Two input dialects are accepted, in order:
// 1. Strict JSON — `[{"fieldName":"x","type":"text"}]`
// 2. YAML (flow) — `[{fieldName: x, type: text}]`
//
// YAML is a superset of JSON that permits unquoted keys and strings, which
// dramatically reduces the need for shell-level escaping. Users can therefore
// write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes
// around every token. The output shape is the same either way; downstream
// consumers see the parsed Go value, not the original dialect.
func transformJSONParse(value any) (any, error) {
s, ok := toString(value)
if !ok {
@@ -119,11 +133,22 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("json_parse: invalid JSON: %v", err))
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
return parsed, nil
// YAML (flow) fallback — accepts `{key: value}` without surrounding
// quotes, which is the natural form when typing at a shell prompt.
if err := yaml.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
)
}
func transformEnumMap(value any, args map[string]any) (any, error) {
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"reflect"
"testing"
)
// TestJSONParse_StrictJSON covers the primary path: callers passing
// canonical JSON (as generated programmatically or by agents).
func TestJSONParse_StrictJSON(t *testing.T) {
t.Parallel()
input := `[{"fieldName":"title","type":"text"},{"fieldName":"count","type":"number"}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("strict JSON should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 2 {
t.Fatalf("expected []any of length 2, got %T %v", got, got)
}
}
// TestJSONParse_YAMLFlowFallback is the motivating case: a user types an
// ad-hoc JSON-shaped array without quoting every key and value. YAML flow
// syntax accepts it and the parsed output is indistinguishable from the
// strict-JSON equivalent.
func TestJSONParse_YAMLFlowFallback(t *testing.T) {
t.Parallel()
// Intentionally unquoted keys, unquoted string values, and Chinese
// identifiers — typical of what humans type at a shell.
input := `[{fieldName: 标题, type: text}, {fieldName: 数量, type: number, config: {formatter: INT}}, {fieldName: 状态, type: singleSelect, config: {options: [{name: 待办}, {name: 进行中}, {name: 已完成}]}}, {fieldName: 已确认, type: checkbox}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("YAML-flow input should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok {
t.Fatalf("expected []any, got %T", got)
}
if len(arr) != 4 {
t.Fatalf("expected 4 field definitions, got %d", len(arr))
}
// Spot-check the third entry, which is the most deeply nested.
third, ok := arr[2].(map[string]any)
if !ok {
t.Fatalf("arr[2] expected map[string]any, got %T", arr[2])
}
if third["fieldName"] != "状态" {
t.Errorf("arr[2].fieldName: want 状态, got %v", third["fieldName"])
}
config, ok := third["config"].(map[string]any)
if !ok {
t.Fatalf("arr[2].config expected map, got %T", third["config"])
}
options, ok := config["options"].([]any)
if !ok || len(options) != 3 {
t.Fatalf("arr[2].config.options: want 3 items, got %v", config["options"])
}
}
// TestJSONParse_EmptyString preserves the legacy behaviour of returning the
// original value untouched when the caller passes an empty / whitespace-only
// string, matching how other transforms treat empty input.
func TestJSONParse_EmptyString(t *testing.T) {
t.Parallel()
cases := []string{"", " ", "\n\t"}
for _, in := range cases {
got, err := ApplyTransform(in, "json_parse", nil)
if err != nil {
t.Errorf("empty input %q should not error: %v", in, err)
continue
}
if !reflect.DeepEqual(got, in) {
t.Errorf("empty input %q should pass through, got %v", in, got)
}
}
}
// TestJSONParse_NonString passes through non-string inputs (already-parsed
// values flowing through the pipeline).
func TestJSONParse_NonString(t *testing.T) {
t.Parallel()
preParsed := []any{map[string]any{"k": "v"}}
got, err := ApplyTransform(preParsed, "json_parse", nil)
if err != nil {
t.Fatalf("non-string should pass through: %v", err)
}
if !reflect.DeepEqual(got, preParsed) {
t.Errorf("non-string should pass through unchanged, got %v", got)
}
}
// TestJSONParse_InvalidInput verifies that genuine garbage is still rejected
// with a user-facing validation error that nudges towards `@file` syntax.
func TestJSONParse_InvalidInput(t *testing.T) {
t.Parallel()
// Unterminated bracket — neither valid JSON nor valid YAML flow.
_, err := ApplyTransform("[{fieldName:", "json_parse", nil)
if err == nil {
t.Fatal("expected error for malformed input")
}
if msg := err.Error(); msg == "" {
t.Fatal("error message should be non-empty")
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
"bot",
tool,
params,
)
+51
View File
@@ -43,4 +43,55 @@ func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) {
if got := runner.last.Params["robotCode"]; got != "robot-001" {
t.Fatalf("robotCode = %#v, want robot-001", got)
}
if got := runner.last.CanonicalProduct; got != "bot" {
t.Fatalf("CanonicalProduct = %q, want bot", got)
}
}
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
}{
{
name: "single-chat",
args: []string{
"--users", "user-001",
"--robot-code", "robot-001",
"--title", "t",
"--text", "x",
},
wantTool: "batch_send_robot_msg_to_users",
},
{
name: "group-chat",
args: []string{
"--group", "cid-xyz",
"--robot-code", "robot-001",
"--title", "t",
"--text", "x",
},
wantTool: "send_robot_group_message",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendByBotCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.CanonicalProduct; got != "bot" {
t.Fatalf("CanonicalProduct = %q, want bot", got)
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
})
}
}
+117
View File
@@ -49,6 +49,29 @@ type CLIFlagHint struct {
Alias string `json:"alias,omitempty"`
}
// FlagOverlay carries CLI-layer transformation metadata for a single
// MCP parameter: the flag alias the user types, the transform applied
// before dispatch, env-var fallback, default value, and whether the flag
// is hidden from help. Sourced from market.CLIToolOverride.Flags.
type FlagOverlay struct {
Alias string `json:"alias,omitempty"`
Transform string `json:"transform,omitempty"`
TransformArgs map[string]any `json:"transform_args,omitempty"`
EnvDefault string `json:"env_default,omitempty"`
Default string `json:"default,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
// ToolAnnotations mirrors MCP 2025+ tool annotations. All hints are
// nullable: absence means "unknown", not "false". Populate only when the
// source has a clear signal — don't guess.
type ToolAnnotations struct {
DestructiveHint *bool `json:"destructive_hint,omitempty"`
ReadOnlyHint *bool `json:"read_only_hint,omitempty"`
IdempotentHint *bool `json:"idempotent_hint,omitempty"`
OpenWorldHint *bool `json:"open_world_hint,omitempty"`
}
type CanonicalProduct struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
@@ -67,13 +90,16 @@ type CanonicalProduct struct {
type ToolDescriptor struct {
RPCName string `json:"rpc_name"`
CLIName string `json:"cli_name,omitempty"`
Group string `json:"group,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
Sensitive bool `json:"sensitive"`
Annotations *ToolAnnotations `json:"annotations,omitempty"`
Hidden bool `json:"hidden,omitempty"`
FlagHints map[string]CLIFlagHint `json:"flag_hints,omitempty"`
FlagOverlay map[string]FlagOverlay `json:"flag_overlay,omitempty"`
SourceServerKey string `json:"source_server_key"`
CanonicalPath string `json:"canonical_path"`
}
@@ -105,10 +131,58 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog {
sensitiveByTool := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
hasSensitiveOverride := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolCLIName := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolGroup := make(map[string]string)
toolHidden := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolTitleOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolDescriptionOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolFlagHints := make(map[string]map[string]CLIFlagHint, len(runtimeServer.Server.CLI.Tools))
toolFlagOverlay := make(map[string]map[string]FlagOverlay)
// Merge richer market.CLIToolOverride data (group, cliName, flag
// transforms/aliases/env-defaults) that the dynamic compat layer
// already consumes but the IR previously dropped.
for name, override := range runtimeServer.Server.CLI.ToolOverrides {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if cn := strings.TrimSpace(override.CLIName); cn != "" {
toolCLIName[trimmed] = cn
}
if gp := strings.TrimSpace(override.Group); gp != "" {
toolGroup[trimmed] = gp
}
if override.Hidden {
toolHidden[trimmed] = true
}
if override.IsSensitive {
sensitiveByTool[trimmed] = true
hasSensitiveOverride[trimmed] = true
}
if desc := strings.TrimSpace(override.Description); desc != "" && toolDescriptionOverride[trimmed] == "" {
toolDescriptionOverride[trimmed] = desc
}
if len(override.Flags) > 0 {
overlay := make(map[string]FlagOverlay, len(override.Flags))
for paramName, flagOverride := range override.Flags {
paramTrim := strings.TrimSpace(paramName)
if paramTrim == "" {
continue
}
overlay[paramTrim] = FlagOverlay{
Alias: strings.TrimSpace(flagOverride.Alias),
Transform: strings.TrimSpace(flagOverride.Transform),
TransformArgs: cloneTransformArgs(flagOverride.TransformArgs),
EnvDefault: strings.TrimSpace(flagOverride.EnvDefault),
Default: strings.TrimSpace(flagOverride.Default),
Hidden: flagOverride.Hidden,
}
}
if len(overlay) > 0 {
toolFlagOverlay[trimmed] = overlay
}
}
}
for _, cliTool := range runtimeServer.Server.CLI.Tools {
name := strings.TrimSpace(cliTool.Name)
if name == "" {
@@ -164,13 +238,16 @@ func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog {
tools = append(tools, ToolDescriptor{
RPCName: tool.Name,
CLIName: cliName,
Group: strings.TrimSpace(toolGroup[tool.Name]),
Title: title,
Description: description,
InputSchema: cloneMap(tool.InputSchema),
OutputSchema: cloneMap(tool.OutputSchema),
Sensitive: sensitive,
Annotations: deriveAnnotations(sensitive),
Hidden: toolHidden[tool.Name],
FlagHints: cloneFlagHints(toolFlagHints[tool.Name]),
FlagOverlay: cloneFlagOverlay(toolFlagOverlay[tool.Name]),
SourceServerKey: runtimeServer.Server.Key,
CanonicalPath: fmt.Sprintf("%s.%s", productID, tool.Name),
})
@@ -363,3 +440,43 @@ func cloneFlagHints(value map[string]CLIFlagHint) map[string]CLIFlagHint {
}
return out
}
func cloneFlagOverlay(value map[string]FlagOverlay) map[string]FlagOverlay {
if len(value) == 0 {
return nil
}
out := make(map[string]FlagOverlay, len(value))
for key, overlay := range value {
overlay.TransformArgs = cloneTransformArgs(overlay.TransformArgs)
out[key] = overlay
}
return out
}
func cloneTransformArgs(value map[string]any) map[string]any {
if len(value) == 0 {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var cloned map[string]any
if err := json.Unmarshal(data, &cloned); err != nil {
return nil
}
return cloned
}
// deriveAnnotations maps the coarse-grained Sensitive bool onto MCP 2025+
// annotation hints. Only DestructiveHint is populated — the other hints
// (read_only, idempotent, open_world) stay nil until the upstream tool
// manifest advertises them explicitly. Guessing from name prefixes
// ("list_", "get_") risks false signals for AI agents.
func deriveAnnotations(sensitive bool) *ToolAnnotations {
if !sensitive {
return nil
}
destructive := true
return &ToolAnnotations{DestructiveHint: &destructive}
}
+108
View File
@@ -216,6 +216,114 @@ func TestBuildCatalogFallsBackToRuntimeSensitiveMetadata(t *testing.T) {
}
}
func TestBuildCatalogCarriesToolOverrideGroupAndFlagOverlay(t *testing.T) {
t.Parallel()
catalog := BuildCatalog([]discovery.RuntimeServer{
{
Server: market.ServerDescriptor{
Key: "ding-key",
DisplayName: "DING",
Endpoint: "https://example.com/server/ding",
CLI: market.CLIOverlay{
Command: "ding",
ToolOverrides: map[string]market.CLIToolOverride{
"send_ding_message": {
CLIName: "send",
Group: "message",
IsSensitive: true,
Flags: map[string]market.CLIFlagOverride{
"receiverUserIdList": {
Alias: "users",
Transform: "csv_to_array",
},
"robotCode": {
Alias: "robot-code",
EnvDefault: "DINGTALK_DING_ROBOT_CODE",
},
"remindType": {
Alias: "type",
Transform: "enum_map",
TransformArgs: map[string]any{
"_default": float64(1),
"app": float64(1),
},
},
},
},
},
},
},
Tools: []transport.ToolDescriptor{
{Name: "send_ding_message", Title: "发送DING消息", InputSchema: map[string]any{"type": "object"}},
},
},
})
if len(catalog.Products) != 1 {
t.Fatalf("products len = %d, want 1", len(catalog.Products))
}
product := catalog.Products[0]
if product.ID != "ding" {
t.Fatalf("product ID = %q, want ding", product.ID)
}
tool, ok := product.FindTool("send_ding_message")
if !ok {
t.Fatalf("tool not found")
}
if tool.CLIName != "send" {
t.Fatalf("CLIName = %q, want send", tool.CLIName)
}
if tool.Group != "message" {
t.Fatalf("Group = %q, want message", tool.Group)
}
if !tool.Sensitive {
t.Fatalf("Sensitive = false, want true (from overlay)")
}
if tool.Annotations == nil || tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
t.Fatalf("DestructiveHint not propagated, got %#v", tool.Annotations)
}
if tool.Annotations.ReadOnlyHint != nil {
t.Fatalf("ReadOnlyHint should be nil when source is unknown, got %#v", *tool.Annotations.ReadOnlyHint)
}
users := tool.FlagOverlay["receiverUserIdList"]
if users.Alias != "users" || users.Transform != "csv_to_array" {
t.Fatalf("receiverUserIdList overlay = %#v", users)
}
robot := tool.FlagOverlay["robotCode"]
if robot.Alias != "robot-code" || robot.EnvDefault != "DINGTALK_DING_ROBOT_CODE" {
t.Fatalf("robotCode overlay = %#v", robot)
}
remind := tool.FlagOverlay["remindType"]
if remind.Transform != "enum_map" {
t.Fatalf("remindType.Transform = %q, want enum_map", remind.Transform)
}
if v, _ := remind.TransformArgs["app"].(float64); v != 1 {
t.Fatalf("remindType.TransformArgs[app] = %v, want 1", remind.TransformArgs["app"])
}
}
func TestBuildCatalogLeavesAnnotationsNilForNonSensitive(t *testing.T) {
t.Parallel()
catalog := BuildCatalog([]discovery.RuntimeServer{
{
Server: market.ServerDescriptor{
Key: "doc-key",
DisplayName: "文档",
Endpoint: "https://example.com/server/doc",
},
Tools: []transport.ToolDescriptor{
{Name: "list_documents", InputSchema: map[string]any{"type": "object"}},
},
},
})
tool := catalog.Products[0].Tools[0]
if tool.Annotations != nil {
t.Fatalf("Annotations = %#v, want nil for non-sensitive tool", tool.Annotations)
}
}
func TestBuildCatalogConsumesCLIRouteMetadata(t *testing.T) {
t.Parallel()
+8 -3
View File
@@ -29,9 +29,10 @@ import (
type Format string
const (
FormatJSON Format = "json"
FormatTable Format = "table"
FormatRaw Format = "raw"
FormatJSON Format = "json"
FormatTable Format = "table"
FormatRaw Format = "raw"
FormatPretty Format = "pretty"
)
var preferredListKeys = []string{"items", "results", "data", "list", "records", "tools", "servers", "products"}
@@ -75,6 +76,8 @@ func Write(w io.Writer, format Format, payload any) error {
return writeRaw(w, payload)
case FormatTable:
return writeTableish(w, payload)
case FormatPretty:
return writePretty(w, payload)
default:
return WriteJSON(w, payload)
}
@@ -123,6 +126,8 @@ func normalizeFormat(raw string, fallback Format) Format {
return FormatRaw
case string(FormatTable):
return FormatTable
case string(FormatPretty):
return FormatPretty
default:
return fallback
}
+302
View File
@@ -0,0 +1,302 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"github.com/fatih/color"
)
// writePretty renders the payload as ANSI-colored, human-readable text.
// If the payload looks like a `dws schema` response (has `kind: "schema"`),
// a schema-specific hierarchical renderer runs. Anything else falls back
// to the table renderer so `--format pretty` is safe on any command.
func writePretty(w io.Writer, payload any) error {
normalized, err := normalizePayload(payload)
if err != nil {
return err
}
if m, ok := normalized.(map[string]any); ok {
if kind, _ := m["kind"].(string); kind == "schema" {
return writeSchemaPretty(w, m)
}
}
return writeTableish(w, normalized)
}
// writeSchemaPretty handles both the list-all shape (has `products`) and
// the single-tool shape (has `tool`). Colours are on by default via fatih/color,
// auto-disabled when stdout is not a TTY or when NO_COLOR is set.
func writeSchemaPretty(w io.Writer, payload map[string]any) error {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
red := color.New(color.FgRed, color.Bold).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
if tool, ok := payload["tool"].(map[string]any); ok {
return writeSchemaToolPretty(w, payload, tool, bold, cyan, green, yellow, red, dim)
}
if products, ok := payload["products"].([]any); ok {
return writeSchemaListPretty(w, payload, products, bold, cyan, dim)
}
if degraded, _ := payload["degraded"].(bool); degraded {
reason, _ := payload["reason"].(string)
hint, _ := payload["hint"].(string)
fmt.Fprintf(w, "%s discovery degraded: %s\n", red("!"), yellow(reason))
if hint != "" {
fmt.Fprintf(w, " %s %s\n", dim("hint:"), hint)
}
return nil
}
return writeTableish(w, payload)
}
func writeSchemaListPretty(
w io.Writer,
payload map[string]any,
products []any,
bold, cyan, dim func(...any) string,
) error {
count, _ := payload["count"].(float64)
if count == 0 {
count = float64(len(products))
}
fmt.Fprintf(w, "%s %s products discovered\n", bold("Catalog"), cyan(fmt.Sprintf("%d", int(count))))
for _, raw := range products {
p, ok := raw.(map[string]any)
if !ok {
continue
}
id, _ := p["id"].(string)
name, _ := p["name"].(string)
desc, _ := p["description"].(string)
tools, _ := p["tools"].([]any)
fmt.Fprintf(w, "\n%s %s %s\n", bold("▸"), bold(id), dim(name))
if desc != "" && desc != name {
fmt.Fprintf(w, " %s\n", dim(desc))
}
fmt.Fprintf(w, " %s %s\n", dim("tools:"), cyan(fmt.Sprintf("%d", len(tools))))
shown := 0
for _, t := range tools {
tm, ok := t.(map[string]any)
if !ok {
continue
}
rpc, _ := tm["name"].(string)
cli, _ := tm["cli_name"].(string)
if cli == "" || cli == rpc {
fmt.Fprintf(w, " - %s\n", rpc)
} else {
fmt.Fprintf(w, " - %s %s\n", rpc, dim("→ "+cli))
}
shown++
if shown >= 6 && len(tools) > 8 {
fmt.Fprintf(w, " %s\n", dim(fmt.Sprintf("… %d more", len(tools)-shown)))
break
}
}
}
return nil
}
func writeSchemaToolPretty(
w io.Writer,
payload map[string]any,
tool map[string]any,
bold, cyan, green, yellow, red, dim func(...any) string,
) error {
rpc, _ := tool["name"].(string)
cli, _ := tool["cli_name"].(string)
title, _ := tool["title"].(string)
desc, _ := tool["description"].(string)
group, _ := tool["group"].(string)
canonical, _ := tool["canonical_path"].(string)
header := rpc
if title != "" && title != rpc {
header = fmt.Sprintf("%s %s", rpc, dim(title))
}
fmt.Fprintf(w, "%s %s\n", bold("Tool"), header)
var productID string
if product, ok := payload["product"].(map[string]any); ok {
pid, _ := product["id"].(string)
pname, _ := product["name"].(string)
productID = pid
fmt.Fprintf(w, " %s %s %s\n", dim("product:"), pid, dim(pname))
}
if canonical != "" {
fmt.Fprintf(w, " %s %s\n", dim("canonical:"), canonical)
}
if cli != "" {
parts := []string{}
if productID != "" {
parts = append(parts, productID)
}
if group != "" {
parts = append(parts, strings.Split(group, ".")...)
}
parts = append(parts, cli)
fmt.Fprintf(w, " %s %s\n", dim("cli path:"), cyan(strings.Join(parts, " ")))
}
// Sensitivity + annotations in one line.
sensitive, _ := tool["sensitive"].(bool)
if sensitive {
fmt.Fprintf(w, " %s %s\n", dim("sensitive:"), red("yes (needs --yes)"))
}
if ann, ok := tool["annotations"].(map[string]any); ok && len(ann) > 0 {
var parts []string
for _, key := range sortedMapKeys(ann) {
parts = append(parts, fmt.Sprintf("%s=%v", key, ann[key]))
}
fmt.Fprintf(w, " %s %s\n", dim("annotations:"), yellow(strings.Join(parts, " ")))
}
if desc != "" && desc != title {
fmt.Fprintln(w)
for _, line := range strings.Split(strings.TrimSpace(desc), "\n") {
fmt.Fprintf(w, " %s\n", dim(line))
}
}
// Parameters section.
if params, ok := tool["parameters"].(map[string]any); ok && len(params) > 0 {
required := map[string]bool{}
if req, ok := tool["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
required[s] = true
}
}
}
overlay := map[string]map[string]any{}
if ov, ok := tool["flag_overlay"].(map[string]any); ok {
for name, v := range ov {
if m, ok := v.(map[string]any); ok {
overlay[name] = m
}
}
}
fmt.Fprintln(w)
fmt.Fprintf(w, "%s\n", bold("Parameters"))
for _, name := range sortedMapKeys(params) {
prop, _ := params[name].(map[string]any)
writeParamPretty(w, name, prop, required[name], overlay[name],
bold, cyan, green, yellow, red, dim)
}
}
// Output schema, if any.
if out, ok := tool["output_schema"].(map[string]any); ok && len(out) > 0 {
fmt.Fprintln(w)
fmt.Fprintf(w, "%s\n", bold("Output schema"))
b, _ := json.MarshalIndent(out, " ", " ")
fmt.Fprintf(w, " %s\n", string(b))
}
return nil
}
func writeParamPretty(
w io.Writer,
name string,
prop map[string]any,
required bool,
overlay map[string]any,
bold, cyan, green, yellow, red, dim func(...any) string,
) {
typeStr := describeType(prop)
marker := " "
if required {
marker = red("*")
}
alias, _ := overlay["alias"].(string)
line := fmt.Sprintf(" %s %s", marker, bold(name))
if alias != "" && alias != name {
line += fmt.Sprintf(" %s", cyan("--"+alias))
}
line += fmt.Sprintf(" %s", dim(typeStr))
fmt.Fprintln(w, line)
if d, _ := prop["description"].(string); d != "" {
for _, ln := range strings.Split(strings.TrimSpace(d), "\n") {
fmt.Fprintf(w, " %s\n", dim(ln))
}
}
// enum values — render inline.
if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
vals := make([]string, 0, len(enum))
for _, v := range enum {
vals = append(vals, fmt.Sprintf("%v", v))
}
fmt.Fprintf(w, " %s %s\n", dim("enum:"), green(strings.Join(vals, ", ")))
}
// overlay — transform / env default / default.
if transform, _ := overlay["transform"].(string); transform != "" {
extras := transform
if args, ok := overlay["transform_args"].(map[string]any); ok && len(args) > 0 {
kvs := make([]string, 0, len(args))
for _, k := range sortedMapKeys(args) {
kvs = append(kvs, fmt.Sprintf("%s=%v", k, args[k]))
}
extras += "(" + strings.Join(kvs, ", ") + ")"
}
fmt.Fprintf(w, " %s %s\n", dim("transform:"), yellow(extras))
}
if env, _ := overlay["env_default"].(string); env != "" {
fmt.Fprintf(w, " %s %s\n", dim("env default:"), green("$"+env))
}
if def, _ := overlay["default"].(string); def != "" {
fmt.Fprintf(w, " %s %s\n", dim("default:"), green(def))
}
}
func describeType(prop map[string]any) string {
t, _ := prop["type"].(string)
if t == "array" {
if items, ok := prop["items"].(map[string]any); ok {
if inner, _ := items["type"].(string); inner != "" {
return inner + "[]"
}
}
return "array"
}
if t == "" {
return "any"
}
return t
}
func sortedMapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"strings"
"testing"
"github.com/fatih/color"
)
// forceNoColor disables ANSI codes for assertion simplicity. fatih/color
// would already do this when writing to a non-TTY, but tests use bytes.Buffer
// so we flip the flag explicitly.
func forceNoColor(t *testing.T) {
t.Helper()
prev := color.NoColor
color.NoColor = true
t.Cleanup(func() { color.NoColor = prev })
}
func TestPretty_SchemaListRendersProducts(t *testing.T) {
forceNoColor(t)
payload := map[string]any{
"kind": "schema",
"count": 2,
"products": []any{
map[string]any{
"id": "ding",
"name": "DING消息",
"description": "DING 消息 / 发送 / 撤回",
"tools": []any{
map[string]any{"name": "send_ding_message", "cli_name": "send"},
map[string]any{"name": "recall_ding_message", "cli_name": "recall"},
},
},
map[string]any{
"id": "doc",
"name": "钉钉文档",
"tools": []any{map[string]any{"name": "create_document", "cli_name": "create"}},
},
},
}
var buf bytes.Buffer
if err := writePretty(&buf, payload); err != nil {
t.Fatalf("writePretty() error = %v", err)
}
out := buf.String()
wants := []string{
"Catalog",
"2 products discovered",
"ding",
"send_ding_message",
"→ send",
"recall_ding_message",
"doc",
"create_document",
}
for _, want := range wants {
if !strings.Contains(out, want) {
t.Errorf("pretty list missing %q; got:\n%s", want, out)
}
}
}
func TestPretty_SchemaToolRendersAllSections(t *testing.T) {
forceNoColor(t)
payload := map[string]any{
"kind": "schema",
"path": "ding.send_ding_message",
"product": map[string]any{
"id": "ding",
"name": "DING消息",
},
"tool": map[string]any{
"name": "send_ding_message",
"cli_name": "send",
"canonical_path": "ding.send_ding_message",
"group": "message",
"title": "发送DING消息",
"description": "使用企业内机器人发送DING消息",
"sensitive": true,
"annotations": map[string]any{
"destructive_hint": true,
},
"parameters": map[string]any{
"robotCode": map[string]any{
"type": "string",
"description": "机器人Code",
},
"receiverUserIdList": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "接收者用户ID列表",
},
"remindType": map[string]any{
"type": "number",
"description": "提醒类型",
},
},
"required": []any{"robotCode", "receiverUserIdList", "remindType"},
"flag_overlay": map[string]any{
"receiverUserIdList": map[string]any{
"alias": "users",
"transform": "csv_to_array",
},
"robotCode": map[string]any{
"alias": "robot-code",
"env_default": "DINGTALK_DING_ROBOT_CODE",
},
"remindType": map[string]any{
"alias": "type",
"transform": "enum_map",
"transform_args": map[string]any{
"app": 1,
"sms": 2,
"call": 3,
"_default": 1,
},
},
},
},
}
var buf bytes.Buffer
if err := writePretty(&buf, payload); err != nil {
t.Fatalf("writePretty() error = %v", err)
}
out := buf.String()
wants := []string{
"Tool send_ding_message", // header
"canonical:", // meta section
"ding.send_ding_message",
"cli path:",
"ding message send",
"sensitive:",
"yes (needs --yes)",
"annotations:",
"destructive_hint=true",
"Parameters",
"robotCode",
"--robot-code", // overlay alias rendered
"env default:",
"$DINGTALK_DING_ROBOT_CODE",
"receiverUserIdList",
"--users",
"transform:",
"csv_to_array",
"string[]", // array<string> shorthand
"remindType",
"--type",
"enum_map(", // with args inlined
"app=1",
}
for _, want := range wants {
if !strings.Contains(out, want) {
t.Errorf("pretty tool missing %q; full output:\n%s", want, out)
}
}
// required params must show the red asterisk marker (even with color disabled
// the literal '*' still appears).
for _, req := range []string{"robotCode", "receiverUserIdList", "remindType"} {
// format: " * <name>"
if !strings.Contains(out, "* "+req) {
t.Errorf("required marker missing for %q", req)
}
}
}
func TestPretty_NonSchemaFallsBackToTableish(t *testing.T) {
forceNoColor(t)
// Payload that doesn't have kind="schema" should fall through to
// the tableish renderer, not error or render a schema header.
payload := map[string]any{"items": []any{
map[string]any{"id": "a", "name": "Alice"},
map[string]any{"id": "b", "name": "Bob"},
}}
var buf bytes.Buffer
if err := writePretty(&buf, payload); err != nil {
t.Fatalf("writePretty() error = %v", err)
}
out := buf.String()
if strings.Contains(out, "Catalog") || strings.Contains(out, "Tool ") {
t.Errorf("pretty wrongly ran schema renderer on non-schema payload:\n%s", out)
}
// tableish should produce header + rows
for _, want := range []string{"id", "name", "Alice", "Bob"} {
if !strings.Contains(out, want) {
t.Errorf("tableish fallback missing %q:\n%s", want, out)
}
}
}
func TestNormalizeFormat_RecognisesPretty(t *testing.T) {
if got := normalizeFormat("pretty", FormatJSON); got != FormatPretty {
t.Errorf("normalizeFormat(pretty) = %q, want %q", got, FormatPretty)
}
if got := normalizeFormat("PRETTY", FormatJSON); got != FormatPretty {
t.Errorf("normalizeFormat(PRETTY) = %q, want %q", got, FormatPretty)
}
}
-3
View File
@@ -131,9 +131,6 @@ func (p *Plugin) ToServerDescriptors() []market.ServerDescriptor {
}
source := "plugin"
if p.IsManaged {
source = "plugin-managed"
}
// Resolve headers: expand environment variable references (e.g. ${DASHSCOPE_API_KEY}).
var resolvedHeaders map[string]string
+16 -87
View File
@@ -56,13 +56,6 @@ type Settings struct {
DevPlugins map[string]string `json:"devPlugins,omitempty"` // name → absolute path
}
// LoadManaged scans ~/.dws/plugins/managed/ and returns all valid
// official plugins. Managed plugins are always enabled.
func (l *Loader) LoadManaged() []*Plugin {
managedDir := filepath.Join(l.PluginsDir, "managed")
return l.scanDir(managedDir, true)
}
// LoadUser scans ~/.dws/plugins/user/ and returns enabled user plugins.
func (l *Loader) LoadUser() []*Plugin {
userDir := filepath.Join(l.PluginsDir, "user")
@@ -86,7 +79,7 @@ func (l *Loader) LoadUser() []*Plugin {
// Check if this is a direct plugin directory (has plugin.json)
if _, err := os.Stat(filepath.Join(entryPath, "plugin.json")); err == nil {
p := l.loadPlugin(entryPath, false)
p := l.loadPlugin(entryPath)
if p != nil && isPluginEnabled(settings, p.Manifest.Name) {
plugins = append(plugins, p)
}
@@ -103,7 +96,7 @@ func (l *Loader) LoadUser() []*Plugin {
continue
}
subPath := filepath.Join(entryPath, sub.Name())
p := l.loadPlugin(subPath, false)
p := l.loadPlugin(subPath)
if p != nil {
qualifiedName := entry.Name() + "/" + p.Manifest.Name
if isPluginEnabled(settings, qualifiedName) {
@@ -115,39 +108,15 @@ func (l *Loader) LoadUser() []*Plugin {
return plugins
}
// LoadAll loads both managed and user plugins.
// LoadAll loads user + dev plugins.
func (l *Loader) LoadAll() []*Plugin {
managed := l.LoadManaged()
user := l.LoadUser()
return append(managed, user...)
}
// scanDir reads a directory of plugin subdirectories and loads each one.
func (l *Loader) scanDir(dir string, isManaged bool) []*Plugin {
entries, err := os.ReadDir(dir)
if err != nil {
if !os.IsNotExist(err) {
slog.Debug("plugin: cannot read dir", "path", dir, "error", err)
}
return nil
}
var plugins []*Plugin
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
p := l.loadPlugin(pluginDir, isManaged)
if p != nil {
plugins = append(plugins, p)
}
}
return plugins
dev := l.LoadDev()
return append(user, dev...)
}
// loadPlugin reads and validates a single plugin directory.
func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin {
func (l *Loader) loadPlugin(dir string) *Plugin {
manifestPath := filepath.Join(dir, "plugin.json")
manifest, err := ParseManifest(manifestPath)
if err != nil {
@@ -163,9 +132,8 @@ func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin {
}
return &Plugin{
Manifest: *manifest,
Root: dir,
IsManaged: isManaged,
Manifest: *manifest,
Root: dir,
}
}
@@ -211,7 +179,7 @@ func isPluginEnabled(s *Settings, name string) bool {
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"` // "managed" or "user"
Type string `json:"type"` // "user" or "dev"
Enabled bool `json:"enabled"`
Path string `json:"path"`
Description string `json:"description,omitempty"`
@@ -222,29 +190,6 @@ func (l *Loader) ListInstalled() []PluginInfo {
var result []PluginInfo
settings := l.loadSettings()
// Managed plugins
managedDir := filepath.Join(l.PluginsDir, "managed")
if entries, err := os.ReadDir(managedDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(managedDir, entry.Name())
m, err := ParseManifest(filepath.Join(dir, "plugin.json"))
if err != nil {
continue
}
result = append(result, PluginInfo{
Name: m.Name,
Version: m.Version,
Type: "managed",
Enabled: true, // managed plugins always enabled
Path: dir,
Description: m.Description,
})
}
}
// User plugins
userDir := filepath.Join(l.PluginsDir, "user")
if entries, err := os.ReadDir(userDir); err == nil {
@@ -348,9 +293,8 @@ func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
l.setPluginEnabled(manifest.Name, true)
return &Plugin{
Manifest: *manifest,
Root: destDir,
IsManaged: false,
Manifest: *manifest,
Root: destDir,
}, nil
}
@@ -411,9 +355,8 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
l.setPluginEnabled(qualifiedName, true)
return &Plugin{
Manifest: *manifest,
Root: destDir,
IsManaged: false,
Manifest: *manifest,
Root: destDir,
}, nil
}
@@ -463,19 +406,9 @@ func parseGitURL(gitURL string) (workspace, repoName string, err error) {
return segments[len(segments)-2], segments[len(segments)-1], nil
}
// RemovePlugin removes an installed plugin by name. It searches both the
// user and the legacy managed directories; all plugins are equally
// removable.
// RemovePlugin removes an installed plugin by name.
func (l *Loader) RemovePlugin(name string, keepData bool) error {
pluginDir := l.findUserPluginDir(name)
if pluginDir == "" {
// Fall back to legacy managed/ directory (for plugins installed
// by older CLI builds that wrote under ~/.dws/plugins/managed/).
legacyDir := filepath.Join(l.PluginsDir, config.PluginManagedDir, name)
if _, err := os.Stat(filepath.Join(legacyDir, "plugin.json")); err == nil {
pluginDir = legacyDir
}
}
if pluginDir == "" {
return fmt.Errorf("plugin %q not found", name)
}
@@ -516,12 +449,8 @@ func (l *Loader) purgePluginFromSettings(name string) {
// SetEnabled enables or disables a plugin in settings.json.
func (l *Loader) SetEnabled(name string, enabled bool) error {
// Verify plugin exists
if l.findUserPluginDir(name) == "" {
managedDir := filepath.Join(l.PluginsDir, "managed", name)
if _, err := os.Stat(managedDir); err != nil {
return fmt.Errorf("plugin %q not found", name)
}
return fmt.Errorf("plugin %q not found", name)
}
l.setPluginEnabled(name, enabled)
return nil
@@ -695,7 +624,7 @@ func (l *Loader) LoadDev() []*Plugin {
"name", name, "dir", dir)
continue
}
p := l.loadPlugin(dir, false)
p := l.loadPlugin(dir)
if p != nil {
plugins = append(plugins, p)
slog.Debug("plugin: loaded dev plugin", "name", name, "dir", dir)
+2 -3
View File
@@ -93,9 +93,8 @@ type HookEntry struct {
// Plugin is a loaded, validated plugin ready for injection.
type Plugin struct {
Manifest Manifest
Root string // absolute path to plugin directory
IsManaged bool // true for official (DingTalk-Real-AI) plugins
Manifest Manifest
Root string // absolute path to plugin directory
}
// ParseManifest reads and parses a plugin.json file.
+40 -121
View File
@@ -199,8 +199,7 @@ func TestPluginToServerDescriptors(t *testing.T) {
},
},
},
Root: "/tmp/plugins/conference",
IsManaged: true,
Root: "/tmp/plugins/conference",
}
descriptors := p.ToServerDescriptors()
@@ -217,8 +216,8 @@ func TestPluginToServerDescriptors(t *testing.T) {
if d.Endpoint != "https://mcp.conference.dingtalk.com" {
t.Errorf("endpoint = %q", d.Endpoint)
}
if d.Source != "plugin-managed" {
t.Errorf("source = %q, want plugin-managed", d.Source)
if d.Source != "plugin" {
t.Errorf("source = %q, want plugin", d.Source)
}
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
@@ -273,7 +272,7 @@ func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
t.Errorf("AuthHeaders[X-Custom] = %q, want static-value", d.AuthHeaders["X-Custom"])
}
if d.Source != "plugin" {
t.Errorf("source = %q, want plugin (non-managed)", d.Source)
t.Errorf("source = %q, want plugin", d.Source)
}
}
@@ -354,137 +353,57 @@ func TestLoaderScanEmpty(t *testing.T) {
CLIVersion: "1.0.0",
}
managed := loader.LoadManaged()
if len(managed) != 0 {
t.Errorf("expected 0 managed plugins, got %d", len(managed))
}
user := loader.LoadUser()
if len(user) != 0 {
t.Errorf("expected 0 user plugins, got %d", len(user))
}
}
func TestLoaderLoadManaged(t *testing.T) {
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name": "conference",
"version": "1.0.0",
"type": "managed",
"mcpServers": {
"conference": {
"type": "streamable-http",
"endpoint": "https://example.com"
}
}
}`
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
plugins := loader.LoadManaged()
if len(plugins) != 1 {
t.Fatalf("expected 1 managed plugin, got %d", len(plugins))
}
if plugins[0].Manifest.Name != "conference" {
t.Errorf("name = %q, want conference", plugins[0].Manifest.Name)
}
if !plugins[0].IsManaged {
t.Error("expected IsManaged = true")
}
}
// TestRemoveLegacyManagedPlugin ensures plugins that were installed under
// the legacy ~/.dws/plugins/managed/ directory are now freely removable
// — the old "cannot be removed" privilege has been dropped.
func TestRemoveLegacyManagedPlugin(t *testing.T) {
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(`{"name":"conference","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
if err := loader.RemovePlugin("conference", false); err != nil {
t.Fatalf("unexpected error removing legacy managed plugin: %v", err)
}
if _, err := os.Stat(managedDir); !os.IsNotExist(err) {
t.Errorf("managed plugin dir should be removed, stat err = %v", err)
}
}
// TestRemovePluginPurgesSettings verifies RemovePlugin fully purges the
// plugin's settings — both its enabled flag and any pluginConfigs entry —
// so settings.json does not retain dangling state for a plugin that no
// longer exists on disk. Covers both the user and legacy managed paths.
// longer exists on disk.
func TestRemovePluginPurgesSettings(t *testing.T) {
cases := []struct {
name string
layout string // "user" or "legacy"
pkgName string
}{
{name: "user plugin", layout: "user", pkgName: "my-plugin"},
{name: "legacy managed plugin", layout: "legacy", pkgName: "conference"},
const pkgName = "my-plugin"
dir := t.TempDir()
pluginDir := filepath.Join(dir, "user", pkgName)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"name":"`+pkgName+`","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
var pluginDir string
switch tc.layout {
case "user":
pluginDir = filepath.Join(dir, "user", tc.pkgName)
case "legacy":
pluginDir = filepath.Join(dir, "managed", tc.pkgName)
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"name":"`+tc.pkgName+`","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
// Seed settings.json with an explicit enabled flag and a
// pluginConfigs entry to verify both get purged.
settings := &Settings{
EnabledPlugins: map[string]bool{tc.pkgName: true, "other-plugin": true},
PluginConfigs: map[string]map[string]any{
tc.pkgName: {"API_KEY": "secret"},
"other-plugin": {"TOKEN": "keep-me"},
},
}
loader.saveSettings(settings)
// Seed settings.json with an explicit enabled flag and a
// pluginConfigs entry to verify both get purged.
settings := &Settings{
EnabledPlugins: map[string]bool{pkgName: true, "other-plugin": true},
PluginConfigs: map[string]map[string]any{
pkgName: {"API_KEY": "secret"},
"other-plugin": {"TOKEN": "keep-me"},
},
}
loader.saveSettings(settings)
if err := loader.RemovePlugin(tc.pkgName, false); err != nil {
t.Fatalf("RemovePlugin: %v", err)
}
if err := loader.RemovePlugin(pkgName, false); err != nil {
t.Fatalf("RemovePlugin: %v", err)
}
reloaded := loader.loadSettings()
if _, exists := reloaded.EnabledPlugins[tc.pkgName]; exists {
t.Errorf("EnabledPlugins should not retain removed plugin %q", tc.pkgName)
}
if _, exists := reloaded.PluginConfigs[tc.pkgName]; exists {
t.Errorf("PluginConfigs should not retain removed plugin %q", tc.pkgName)
}
if !reloaded.EnabledPlugins["other-plugin"] {
t.Error("unrelated EnabledPlugins entry should be preserved")
}
if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" {
t.Error("unrelated PluginConfigs entry should be preserved")
}
})
reloaded := loader.loadSettings()
if _, exists := reloaded.EnabledPlugins[pkgName]; exists {
t.Errorf("EnabledPlugins should not retain removed plugin %q", pkgName)
}
if _, exists := reloaded.PluginConfigs[pkgName]; exists {
t.Errorf("PluginConfigs should not retain removed plugin %q", pkgName)
}
if !reloaded.EnabledPlugins["other-plugin"] {
t.Error("unrelated EnabledPlugins entry should be preserved")
}
if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" {
t.Error("unrelated PluginConfigs entry should be preserved")
}
}
-6
View File
@@ -112,12 +112,6 @@ const (
// ── Plugin system ──────────────────────────────────────────────────────
const (
// PluginManagedDir is a legacy subdirectory under ~/.dws/plugins/
// retained so plugins installed by older CLI versions remain loadable.
// New installs always go to PluginUserDir; there is no privilege
// associated with this directory anymore.
PluginManagedDir = "managed"
// PluginUserDir is the subdirectory under ~/.dws/plugins/ where all
// third-party plugins are installed. Every plugin — whether authored
// by the DingTalk team or anyone else — lives here with equal status.
+55 -21
View File
@@ -1,7 +1,7 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档/钉钉文档/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、查询听记纪要时使用。
cli_version: ">=1.0.6"
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要时使用。
cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
@@ -24,39 +24,41 @@ cli_version: ">=1.0.6"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aitable` | AI表格:Base/数据表/字段/记录/附件/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `approval` | 审批:审批表单/发起实例/审批/撤销 | [simple.md](./references/products/simple.md) |
| `attendance` | 考勤:打卡记录/排班查询 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日程/参与者/会议室/闲忙查询 | [calendar.md](./references/products/calendar.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡记录/排班查询/考勤规则/汇总统计 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日程/参与者/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/机器人群发/单聊/撤回/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情)/部门查询(搜索/子部门/成员列表) | [contact.md](./references/products/contact.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [simple.md](./references/products/simple.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/部门查询(搜索/成员列表) | [contact.md](./references/products/contact.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论 | [doc.md](./references/products/doc.md) |
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词 | [minutes.md](./references/products/minutes.md) |
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名 | [doc.md](./references/products/doc.md) |
| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) |
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词/上传 | [minutes.md](./references/products/minutes.md) |
| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `todo` | 待办:创建(含优先级/截止时间)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `workbench` | 工作台:应用管理 | [workbench.md](./references/products/workbench.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
## 意图判断决策树
用户提到"表格/多维表/AI表格/记录/数据" → `aitable`
用户提到"审批/请假/报销/出差/加班" → `oa`
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会" → `calendar`
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
用户提到"通讯录/同事/部门/组织架构" → `contact`
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论" → `doc`
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"待办/TODO/任务提醒" → `todo`
用户提到"工作台/应用管理" → `workbench`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警)
关键区分: doc(钉钉文档/富文本协同) vs drive(钉钉云盘/二进制文件)
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
@@ -68,14 +70,22 @@ cli_version: ">=1.0.6"
| 产品 | 命令 | 说明 |
|------|------|------|
| `aitable` | `base delete` | 删除整个 AI 表格,含全部数据表和记录 |
| `aitable` | `table delete` | 删除数据表(含全部字段/视图/记录) |
| `aitable` | `field delete` | 删除字段(该列所有值同步清空) |
| `aitable` | `view delete` | 删除视图 |
| `aitable` | `record delete` | 删除记录(支持批量) |
| `aitable` | `chart delete` / `dashboard delete` | 删除图表/仪表盘 |
| `calendar` | `event delete` | 删除日程,所有参与者同步取消 |
| `calendar` | `participant delete` | 移除日程参与者 |
| `calendar` | `room delete` | 取消会议室预定 |
| `chat` | `group members remove` | 移除群成员 |
| `doc` | `delete` | 删除钉钉文档(不可恢复) |
| `doc` | `block delete` | 删除文档块 |
| `chat` | `message recall-by-bot` | 撤回机器人已发消息 |
| `doc` | `block delete` | 删除文档块(不可恢复) |
| `ding` | `message recall` | 撤回已发 DING 消息 |
| `oa` | `approval revoke` | 撤销自己发起的审批实例 |
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
### 确认流程
```
@@ -92,6 +102,30 @@ Step 3 → 加 --yes 执行命令
3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。
4. 充分阅读产品参考文件,通过编写代码或直接调用指令实现用户意图。
## 命令发现(flag / 参数以 binary 为准)
产品参考文档(`references/products/*.md`)里的 flag 列表是**便于理解用途的参考**,不是权威契约。参数名称、默认值、必填约束随服务发现动态变化,**以下两个命令的输出才是调用的事实源**:
```bash
# 1) 人读视图:看 Usage / Example / Flags
dws <command-path> --help
# 例:dws calendar event list --help
# 2) 机读视图:JSON Schema + flag 别名映射 + 必填字段
dws schema # 列出所有产品及工具
dws schema <product>.<canonical_name> # 规范路径(如 calendar.list_suggested_event_times)
dws schema "<product> <group> <cli_name>" # CLI 路径(如 "calendar event list")
dws schema <path> --jq '.tool.flag_overlay' # 只看 flag 别名
dws schema <path> --jq '.tool.required' # 只看必填字段
```
**何时用哪条路径:**
- 只需看某个命令怎么调用 → `dws <cmd> --help`
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → `dws schema <path>`
- 参考文档和 `--help` 冲突时 → **以 `--help` / `dws schema` 为准**,文档视为过期
`dws schema` 输出的 `flag_overlay[key].alias` 就是实际生效的 flag 名(如 `attendeeUserIds → --attendee-user-ids`);`parameters[key]` 是原始 JSON Schema;`required` 是必填字段数组;`sensitive: true` 表示写/删操作,须先向用户确认再加 `--yes`。
## 错误处理
1. 遇到错误,加 `--verbose` 重试一次
2. 若 stderr 出现 `RECOVERY_EVENT_ID=<event_id>`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环
@@ -102,7 +136,7 @@ Step 3 → 加 --yes 执行命令
## 详细参考 (按需读取)
- [references/products/](./references/products/) — 各产品命令详细参考
- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
+380 -21
View File
@@ -215,11 +215,12 @@ Example:
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --name "新字段名"
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --config '{"options":[{"name":"A"},{"name":"B"}]}'
Flags:
--base-id string Base ID (必填)
--config string 字段配置 JSON (不修改时省略)
--field-id string Field ID (必填)
--name string 新字段名称 (不修改时省略)
--table-id string Table ID (必填)
--ai-config string AI 字段配置 JSON(AI 字段类型专用,可选)
--base-id string Base ID (必填)
--config string 字段配置 JSON (不修改时省略)
--field-id string Field ID (必填)
--name string 新字段名称 (不修改时省略)
--table-id string Table ID (必填)
```
不可变更字段类型。更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id。
@@ -362,6 +363,328 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
> ⚠️ `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。
### view (视图管理)
视图是同一张数据表的筛选/排序/分组/可见字段组合的备用呈现。record query 可以通过视图收敛查询范围。
#### 获取视图
```
Usage:
dws aitable view get [flags]
Example:
dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids viw1,viw2
Flags:
--base-id string Base ID (必填)
--table-id string Table ID (必填)
--view-ids string View ID 列表,逗号分隔,单次最多 10 个
```
返回视图配置(过滤、排序、可见字段、分组)。不传 view-ids 返回该表全部视图。
#### 创建视图
```
Usage:
dws aitable view create [flags]
Example:
dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --name "进行中看板" --view-type kanban
Flags:
--base-id string Base ID (必填)
--config string 视图配置 JSON:过滤/排序/分组/可见字段(可选,创建后可再 update)
--name string 视图名称 (必填)
--table-id string Table ID (必填)
--view-type string 视图类型:grid/gallery/kanban/gantt/calendar/form 等 (必填)
```
> ⚠️ 视图类型参数是 `--view-type`,不是 `--type`。
#### 更新视图
```
Usage:
dws aitable view update [flags]
Example:
dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --name "新视图名"
Flags:
--base-id string Base ID (必填)
--config string 视图配置 JSON:过滤/排序/分组/可见字段
--name string 新视图名
--table-id string Table ID (必填)
--view-id string 目标 View ID (必填)
```
调整过滤、排序、分组、可见字段时使用。不重建视图即可替换配置。
#### 删除视图
```
Usage:
dws aitable view delete [flags]
Example:
dws aitable view delete --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --yes
Flags:
--base-id string Base ID (必填)
--table-id string Table ID (必填)
--view-id string 待删除 View ID (必填)
```
不可逆。若是主视图或最后一个视图,删除会失败。
### import (数据导入)
把外部 Excel/CSV 批量写入某张数据表,按两步走:先 upload 拿 importId,再 data 触发导入。
#### 准备导入上传
```
Usage:
dws aitable import upload [flags]
Example:
dws aitable import upload --base-id <BASE_ID> --file-name sales.xlsx --file-size 204800
Flags:
--base-id string Base ID (必填)
--file-name string 文件名,必须含扩展名 (必填)
--file-size int 文件大小(字节),>0 (必填)
```
> ⚠️ 参数是 `--file-size`,不是 `--size`。返回 uploadUrl(PUT 上传)与 importId。
#### 触发导入
```
Usage:
dws aitable import data [flags]
Example:
dws aitable import data --import-id <IMPORT_ID> --timeout 60
Flags:
--import-id string import upload 返回的 importId (必填)
--timeout int 等待导入完成的超时秒数,默认 30
```
把上一步上传到暂存区的文件正式导入为记录;超时未完成时返回处理状态,按 importId 再次查询即可。
### dashboard (仪表盘)
仪表盘 = 多个图表的布局容器;图表绑定数据表/视图。创建仪表盘之前通常先查 `dashboard config-example`,拿到 JSON 骨架,再把图表塞进 `--config` 的布局里。
#### 仪表盘配置示例
```
Usage:
dws aitable dashboard config-example [flags]
Example:
dws aitable dashboard config-example --format json
```
返回仪表盘 `--config` 的 JSON 示例,供 create/update 复制裁剪。
#### 获取仪表盘
```
Usage:
dws aitable dashboard get [flags]
Example:
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID>
Flags:
--base-id string Base ID (必填)
--dashboard-id string Dashboard ID (必填)
```
返回布局和 `charts[].chartId`,可直接喂给 `chart get`。
#### 创建仪表盘
```
Usage:
dws aitable dashboard create [flags]
Example:
dws aitable dashboard create --base-id <BASE_ID> --config '<JSON>'
Flags:
--base-id string Base ID (必填)
--config string 仪表盘配置 JSON(名称、布局、图表列表都在里面) (必填)
```
> ⚠️ 没有独立的 `--name`;名称和布局一起放在 `--config` JSON 中。
#### 更新仪表盘
```
Usage:
dws aitable dashboard update [flags]
Example:
dws aitable dashboard update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --config '<JSON>'
Flags:
--base-id string Base ID (必填)
--config string 更新后的仪表盘配置 JSON (必填)
--dashboard-id string Dashboard ID (必填)
```
调整布局、增删图表一律改 `--config`。
#### 删除仪表盘
```
Usage:
dws aitable dashboard delete [flags]
Example:
dws aitable dashboard delete --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --yes
Flags:
--base-id string Base ID (必填)
--dashboard-id string Dashboard ID (必填)
--reason string 删除原因
```
不可逆。
#### 查看仪表盘分享状态
```
Usage:
dws aitable dashboard share get [flags]
Example:
dws aitable dashboard share get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID>
Flags:
--base-id string Base ID (必填)
--dashboard-id string Dashboard ID (必填)
```
> ⚠️ 可能返回 404(资源不存在或未开通外链),按可重试错误处理,不要误判为参数拼错。
#### 更新仪表盘分享
```
Usage:
dws aitable dashboard share update [flags]
Example:
dws aitable dashboard share update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --enabled true
Flags:
--allow-back-to-doc 是否允许从分享页返回原文档(可选)
--base-id string Base ID (必填)
--dashboard-id string Dashboard ID (必填)
--enabled 是否开启外链分享 (必填)
--share-type string 分享类型(权限/可见范围等,可选)
```
开启后返回 shareUrl;关闭后原链接失效。
### chart (图表)
图表挂在某个仪表盘下,绑定数据表(可进一步绑定视图)。新建前通常先查 `chart widgets-example`。
#### 图表组件示例
```
Usage:
dws aitable chart widgets-example [flags]
Example:
dws aitable chart widgets-example --format json
```
返回图表 widget 的 JSON 示例,供 `chart create/update --config` 参考。
#### 获取图表
```
Usage:
dws aitable chart get [flags]
Example:
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID>
Flags:
--base-id string Base ID (必填)
--chart-id string Chart ID (必填)
--dashboard-id string Dashboard ID (必填)
```
#### 创建图表
```
Usage:
dws aitable chart create [flags]
Example:
dws aitable chart create --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --config '<JSON>'
Flags:
--base-id string Base ID (必填)
--config string 图表配置 JSON:数据源、维度、度量、样式 (必填)
--dashboard-id string 挂载到的 Dashboard ID (必填)
--layout string 图表布局 JSON(位置、尺寸,可选)
```
> ⚠️ 名称和类型都在 `--config` JSON 里,没有独立 `--name` / `--type`。
#### 更新图表
```
Usage:
dws aitable chart update [flags]
Example:
dws aitable chart update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --config '<JSON>'
Flags:
--base-id string Base ID (必填)
--chart-id string Chart ID (必填)
--config string 图表配置 JSON (必填)
--dashboard-id string Dashboard ID (必填)
--layout string 图表布局 JSON(位置、尺寸,可选)
```
#### 删除图表
```
Usage:
dws aitable chart delete [flags]
Example:
dws aitable chart delete --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --yes
Flags:
--base-id string Base ID (必填)
--chart-id string 待删除 Chart ID (必填)
--dashboard-id string Dashboard ID (必填)
--reason string 删除原因
```
不可逆。
#### 查看图表分享状态
```
Usage:
dws aitable chart share get [flags]
Example:
dws aitable chart share get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID>
Flags:
--base-id string Base ID (必填)
--chart-id string Chart ID (必填)
--dashboard-id string Dashboard ID (必填)
```
返回 `enabled` 与 `shareUrl`,用来判断是否已经对外分享。
#### 更新图表分享
```
Usage:
dws aitable chart share update [flags]
Example:
dws aitable chart share update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --enabled true
Flags:
--allow-back-to-doc 是否允许从分享页返回原文档(可选)
--base-id string Base ID (必填)
--chart-id string Chart ID (必填)
--dashboard-id string Dashboard ID (必填)
--enabled 是否开启外链分享 (必填)
--share-type string 分享类型(权限/可见范围等,可选)
```
### export (数据导出)
把数据表/视图/整张 Base 导出为 Excel/CSV,下发下载链接;常见是异步任务,首次调用可能只返回 `taskId`,需要按 `taskId` 继续轮询直到拿到 `downloadUrl`。
#### 导出数据
```
Usage:
dws aitable export data [flags]
Example:
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
Flags:
--base-id string Base ID (必填)
--scope string 导出范围:all / table / view
--table-id string Table ID(scope=table/view 时必填)
--view-id string View ID(scope=view 时必填)
--format string 导出格式:excel / csv,默认 excel
--task-id string 轮询已有任务的 taskId
--timeout-ms int 单次调用服务端等待时间(毫秒)
```
参数约束:
- `scope=all`:只需 `--base-id`
- `scope=table`:必须同时传 `--table-id`
- `scope=view`:必须同时传 `--table-id + --view-id`
### template (模板搜索)
#### 搜索模板
@@ -392,6 +715,10 @@ dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
# 3) 写入/更新都用 --config JSON(名称、布局、图表类型都在里面)
dws aitable dashboard create --base-id <BASE_ID> --config '<JSON>' --format json
dws aitable chart update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --config '<JSON>' --format json
```
要点:
@@ -399,24 +726,20 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
- dashboard/chart 的 create/update 只有 `--config`,没有独立的 `--name` / `--type`,名称与布局都在 JSON 里。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
### 导入外部数据(两步走)
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 1) 先上传文件拿 importId
dws aitable import upload --base-id <BASE_ID> --file-name sales.xlsx --file-size 204800 --format json
# 把本地文件 PUT 到返回的 uploadUrl(过期前必须上传完成)
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
# 2) 触发导入
dws aitable import data --import-id <IMPORT_ID> --timeout 60 --format json
```
参数约束
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
要点:`--file-size` 单位字节,必须与实际文件一致;`--timeout` 单位秒,不是毫秒。
## 意图判断
@@ -446,9 +769,37 @@ dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 300
- 修改/更新 → `record update`(需 recordId,先 `record query`)
- 删除 → `record delete` [危险](需 recordId)
用户说"视图/筛选视图/看板/画廊":
- 查看 → `view get`
- 新建 → `view create`(`--view-type` 指定视图类型)
- 修改(过滤/排序/分组/可见字段) → `view update`
- 删除 → `view delete` [危险]
用户说"导入/上传 Excel/CSV":
- 外部文件导入 → `import upload` → PUT 文件 → `import data`(两步)
用户说"导出/下载表格数据":
- → `export data`(按 scope=all/table/view 传参,异步轮询)
用户说"仪表盘/dashboard":
- 查看布局 → `dashboard get`
- 查看模板 → `dashboard config-example`
- 新建/修改布局 → `dashboard create` / `dashboard update`(配置都在 `--config` JSON)
- 删除 → `dashboard delete` [危险]
- 对外分享 → `dashboard share get` / `dashboard share update`
用户说"图表/chart":
- 查看 → `chart get`(需要 dashboardId)
- 查看组件模板 → `chart widgets-example`
- 新建/修改 → `chart create` / `chart update`(配置都在 `--config` JSON)
- 删除 → `chart delete` [危险]
- 对外分享 → `chart share get` / `chart share update`
用户说"附件/文件字段" → `attachment upload`(不要用钉盘 drive)
用户说"模板" → `template search`
关键区分: base=表格文件, table=数据表, field=列, record=行
关键区分: base=表格文件, table=数据表, field=列, record=行, view=视图, dashboard=仪表盘, chart=图表
## 核心工作流
@@ -476,15 +827,23 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `base get` | `tables[].tableId`、`dashboards[].dashboardId` | --table-id / --dashboard-id |
| `table get` | `fields[].fieldId`、`views[].viewId` | record 操作的 cells key;field get/update/delete;view 操作 |
| `view get` | `viewId` | record query 按视图收敛;view update/delete |
| `record query` | `recordId` | record update/delete |
| `dashboard get` | `charts[].chartId` | chart get/update/delete/share |
| `import upload` | `importId`、`uploadUrl` | PUT 文件后调 import data |
| `export data` (首次) | `taskId` | 下一轮 export data 轮询 |
| `attachment upload` | `fileToken` | record create/update 写入 attachment 字段 |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## 注意事项
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- 所有操作使用 ID(baseId/tableId/fieldId/recordId/viewId/dashboardId/chartId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
- dashboard/chart 的 create/update 只有 `--config`(JSON 内含名称/布局/类型),没有独立 `--name`
- view 创建的类型参数是 `--view-type`,不是 `--type`
- import upload 的文件大小参数是 `--file-size`,不是 `--size`
## `--filters` 筛选语法排错与使用规范(极易出错)
+37 -9
View File
@@ -30,11 +30,17 @@ Usage:
Example:
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--attendees userId1,userId2 --timezone "Asia/Shanghai" --desc "Q1 复盘"
Flags:
--desc string 日程描述
--end string 结束时间 ISO-8601 (必填)
--start string 开始时间 ISO-8601 (必填)
--title string 日程标题 (必填)
--attendees string 参会人 userId 列表 (逗号分隔)
--desc string 日程描述
--end string 结束时间 ISO-8601 (必填)
--open-dingtalk-ids string 参会人 openDingTalkId 列表 (逗号分隔)
--start string 开始时间 ISO-8601 (必填)
--timezone string 时区 (如 Asia/Shanghai)
--title string 日程标题 (必填)
```
### 修改日程
@@ -43,11 +49,14 @@ Usage:
dws calendar event update [flags]
Example:
dws calendar event update --id <EVENT_ID> --title "新标题"
dws calendar event update --id <EVENT_ID> --desc "议程调整" --timezone "Asia/Shanghai"
Flags:
--end string 新结束时间
--id string 日程 ID (必填)
--start string 新开始时间
--title string 新标题
--desc string 新描述
--end string 新结束时间
--id string 日程 ID (必填)
--start string 新开始时间
--timezone string 时区 (如 Asia/Shanghai)
--title string 新标题
```
### 删除日程
@@ -60,6 +69,23 @@ Flags:
--id string 日程 ID (必填)
```
### 推荐会议时间
```
Usage:
dws calendar event suggest [flags]
Example:
dws calendar event suggest --users <USER_ID_1>,<USER_ID_2> \
--start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 30
Flags:
--duration string 会议时长(分钟)
--end string 候选时段结束时间 ISO-8601 (必填)
--start string 候选时段开始时间 ISO-8601 (必填)
--timezone string 时区 (如 Asia/Shanghai)
--users string 参会人 userId 列表 (必填)
```
基于参会人闲忙数据给出排名后的候选时段,比 `busy search` 直接给出原始忙闲更适合"帮我约"场景。
### 查看参与者
```
Usage:
@@ -157,6 +183,7 @@ Flags:
- 创建/约 → `event create`
- 修改/改时间 → `event update`
- 取消/删除 → `event delete`
- 帮我约/找个大家都有空的时间 → `event suggest`
用户说"参会人/与会者":
- 查看 → `participant list`
@@ -170,7 +197,8 @@ Flags:
- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`
用户说"有空吗/忙不忙/闲忙":
- 查询 → `busy search`
- 要原始闲忙块 → `busy search`
- 要直接给时段候选 → `event suggest`
## 核心工作流
+580 -131
View File
@@ -1,77 +1,93 @@
# 群聊与机器人 (chat) 命令参考
# 会话与群聊 (chat) 命令参考
> 命令别名: `dws im` 等价于 `dws chat`
## 命令总览
### group (群组管理)
| 子命令 | 用途 |
|-------|------|
| `search` | 搜索群聊 |
| `group create` | 创建群 |
| `group members list` | 查看群成员列表 |
| `group create` | 创建内部群 |
| `group members` | 查看群成员列表 |
| `group members add` | 添加群成员 |
| `group members remove` | 移除群成员(⚠️ 危险操作) |
| `group members add-bot` | 添加机器人到群 |
| `group rename` | 修改群名称 |
| `bot search` | 搜索我的机器人 |
| `search` | 搜索群会话 |
| `search-common` | 搜索共同群 |
| `conversation-info` | 获取会话基础信息(单聊/群聊) |
### message (会话消息管理)
| 子命令 | 用途 |
|-------|------|
| `message send` | 以当前用户身份发群消息或单聊消息 |
| `message list` | 拉取群聊或单聊会话消息 |
| `message list-all` | 按时间范围拉取当前用户所有会话消息 |
| `message list-topic-replies` | 拉取群话题回复消息列表 |
| `message list-by-sender` | 搜索指定发送者的消息 |
| `message list-mentions` | 拉取 @我 的消息 |
| `message list-focused` | 拉取特别关注人的消息 |
| `message list-unread-conversations` | 获取未读会话列表 |
| `message search` | 按关键词搜索消息 |
| `message send-by-bot` | 机器人发消息(群聊或批量单聊) |
| `message recall-by-bot` | 机器人撤回消息(群聊或批量单聊) |
| `message recall-by-bot` | 机器人撤回消息 |
| `message send-by-webhook` | 自定义机器人 Webhook 发消息 |
| `list-top-conversations` | 拉取置顶会话列表 |
### bot (机器人管理)
| 子命令 | 用途 |
|-------|------|
| `bot search` | 搜索我的机器人 |
---
## search — 搜索群聊
## group create — 创建内部群
```
Usage:
dws chat search [flags]
Example:
dws chat search --query "项目冲刺" --format json
Flags:
--query string 搜索关键词 (必填)
--cursor string 分页游标(首页留空)
```
---
## group create — 创建群
当前登录用户自动成为群主。
```
Usage:
dws chat group create [flags]
Example:
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 --format json
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
Flags:
--name string 群名称 (必填)
--users string 群成员 userId 列表,逗号分隔 (必填)
--users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填)
--name string 群名称 (必填)
```
> 当前用户自动作为群主加入,无需在 --users 中重复传入。
---
## group members list — 查看群成员列表
## group members — 查看群成员列表
分页查询指定群聊的成员。
```
Usage:
dws chat group members list [flags]
dws chat group members [flags]
Example:
dws chat group members list --id <openConversationId> --format json
dws chat group members --id <openconversation_id>
Flags:
--id string 群会话 ID (必填)
--cursor string 分页游标
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
```
---
## group members add — 添加群成员
向指定群聊添加成员,需传入群 ID 与用户 ID 列表。
```
Usage:
dws chat group members add [flags]
Example:
dws chat group members add --id <openConversationId> --users userId1,userId2 --format json
dws chat group members add --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群会话 ID (必填)
--users string 要添加的 userId 列表,逗号分隔 (必填)
--id string 群 ID / openconversation_id (必填)
--users string 要添加的用户 userId 列表,逗号分隔 (必填)
```
---
@@ -84,24 +100,26 @@ Flags:
Usage:
dws chat group members remove [flags]
Example:
dws chat group members remove --id <openConversationId> --users userId1,userId2 --format json
dws chat group members remove --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群会话 ID (必填)
--users string 要移除的 userId 列表,逗号分隔 (必填)
--id string 群 ID / openconversation_id (必填)
--users string 要移除的用户 userId 列表,逗号分隔 (必填)
```
---
## group members add-bot — 添加机器人到群
将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。
```
Usage:
dws chat group members add-bot [flags]
Example:
dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json
dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>
Flags:
--id string 群会话 ID (必填)
--robot-code string 机器人 code (必填)
--id string 群聊 openConversationId (必填)
--robot-code string 机器人 Code (必填)
```
---
@@ -112,10 +130,331 @@ Flags:
Usage:
dws chat group rename [flags]
Example:
dws chat group rename --id <openConversationId> --name "新群名" --format json
dws chat group rename --id <openconversation_id> --name "新群名"
Flags:
--id string 群会话 ID (必填)
--name string 新群名称 (必填)
--id string 群 ID / openconversation_id (必填)
--name string 修改后的群名称 (必填)
```
---
## search — 搜索群会话
根据名称搜索会话列表。
```
Usage:
dws chat search [flags]
Example:
dws chat search --query "项目冲刺"
Flags:
--cursor string 分页游标 (首页留空)
--query string 搜索关键词 (必填)
```
---
## search-common — 搜索共同群
根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。
```
Usage:
dws chat search-common [flags]
Example:
dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0
dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0
dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor <nextCursor>
Flags:
--nicks string 要搜索的昵称列表,逗号分隔 (必填)
--match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)
--limit int 每页返回数量(默认 20)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔"
- --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
```
Usage:
dws chat message send [flags] [<text>]
Example:
dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --user <userId> --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
Flags:
--text string 消息内容(推荐使用,也可用位置参数)
--group string 群聊 openconversation_id(群聊时必填)
--user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
--title string 消息标题(可选,默认「消息」)
--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 优先
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --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
- 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file
```
---
## message list — 拉取会话消息内容
拉取指定群聊或单聊的会话消息内容。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(群聊时必填)
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
```
---
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
分页拉取当前登录用户在指定时间范围内的所有会话消息。
--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。
```
Usage:
dws chat message list-all [flags]
Example:
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
Flags:
--start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取)
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
---
## message list-topic-replies — 拉取群话题回复消息列表
查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。
```
Usage:
dws chat message list-topic-replies [flags]
Example:
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId>
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId> --time "2025-03-01 00:00:00" --limit 20
Flags:
--group string 群会话 openconversationId (必填)
--topic-id string 话题 ID,由 dws chat message list 返回 (必填)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)
--limit int 返回数量(默认 50)
--forward true=从老往新,false=从新往老(默认 false)
```
---
## message list-by-sender — 拉取指定发送者的消息
搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。
```
Usage:
dws chat message list-by-sender [flags]
Example:
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
Flags:
--sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一)
--sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一
- 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message list-mentions — 拉取 @我 的消息
搜索时间范围内 @我 的消息,可选指定群聊。
```
Usage:
dws chat message list-mentions [flags]
Example:
dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
Flags:
--group string 群聊 openconversation_id(可选,不传则查全部)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message list-focused — 拉取特别关注人的消息
拉取当前用户特别关注人的消息。
```
Usage:
dws chat message list-focused [flags]
Example:
dws chat message list-focused --limit 50
dws chat message list-focused --limit 20 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 50)
--cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor
```
---
## message list-unread-conversations — 获取未读会话列表
获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。
```
Usage:
dws chat message list-unread-conversations [flags]
Example:
dws chat message list-unread-conversations
dws chat message list-unread-conversations --count 20
Flags:
--count int 返回未读会话条数(可选)
```
---
## message search — 按关键词搜索消息
在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。
```
Usage:
dws chat message search [flags]
Example:
dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --keyword "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
Flags:
--keyword string 搜索关键词 (必填)
--group string 群聊 openconversation_id(可选,不传则搜索所有会话)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 100)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## conversation-info — 获取会话基础信息
按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。
```
Usage:
dws chat conversation-info [flags]
Example:
dws chat conversation-info --group <openConversationId>
dws chat conversation-info --open-dingtalk-id <openDingTalkId>
Flags:
--group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一)
注意:
- --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一
```
---
## list-top-conversations — 拉取置顶会话列表
拉取当前用户的置顶会话列表。
```
Usage:
dws chat list-top-conversations [flags]
Example:
dws chat list-top-conversations --limit 1000
dws chat list-top-conversations --limit 1000 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 1000)
--cursor int 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可
- 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group <openConversationId>` 分别拉取每个会话内的消息
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
@@ -126,161 +465,271 @@ Flags:
Usage:
dws chat bot search [flags]
Example:
dws chat bot search --name "考勤" --format json
dws chat bot search --page 1
dws chat bot search --page 1 --size 10 --name "日报"
Flags:
--name string 机器人名称(模糊搜索)
--page int 页码(默认 1)
--size int 每页数量(默认 50)
--name string 按名称搜索
--page int 页码,从1开始 (默认 1)
--size int 每页条数 (默认 50),别名: --limit
```
---
## message send-by-bot — 机器人发消息
支持两种模式:群聊发送 和 批量单聊发送,通过 `--group` 和 `--users` 互斥区分。
群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。
### 群聊发送
```
Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <code> --group <openConversationId> \
--title "日报提醒" --text "请提交今日日报" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
Flags:
--robot-code string 机器人 code (必填)
--group string 群会话 ID (必填,与 --users 互斥)
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--text string 消息内容,支持 Markdown (必填)
```
--users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
### 批量单聊发送
注意:
- --group 与 --users 互斥,必须且只能指定其一
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
```
Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <code> --users "user1,user2" \
--title "通知" --text "会议已取消" --format json
Flags:
--robot-code string 机器人 code (必填)
--users string 用户 ID 列表,逗号分隔,最多 20 个 (必填,与 --group 互斥)
--title string 消息标题 (必填)
--text string 消息内容,支持 Markdown (必填)
```
> ⚠️ `--group` 和 `--users` 互斥:群聊用 `--group`,单聊用 `--users`,不能同时传。
---
## message recall-by-bot — 机器人撤回消息
支持两种模式:群聊撤回 和 批量单聊撤回。
群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。
### 群聊撤回
```
Usage:
dws chat message recall-by-bot [flags]
Example:
dws chat message recall-by-bot --robot-code <code> --group <openConversationId> \
--keys "key1,key2" --format json
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> --keys <process-query-key>
dws chat message recall-by-bot --robot-code <robot-code> --keys key1,key2
Flags:
--robot-code string 机器人 code (必填)
--group string 群会话 ID (必填,与批量单聊互斥)
--keys string 消息 key 列表,逗号分隔 (必填)
--group string 群聊 openConversationId(群聊撤回时必填)
--keys string 消息 processQueryKey 列表,逗号分隔 (必填)
--robot-code string 机器人 Code (必填)
```
### 批量单聊撤回
```
Usage:
dws chat message recall-by-bot [flags]
Example:
dws chat message recall-by-bot --robot-code <code> --keys "key1,key2" --format json
Flags:
--robot-code string 机器人 code (必填)
--keys string 消息 key 列表,逗号分隔 (必填)
```
> ⚠️ 消息 key 从 `send-by-bot` 返回结果中提取。
---
## message send-by-webhook — 自定义机器人 Webhook 发消息
@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
```
Usage:
dws chat message send-by-webhook [flags]
Example:
dws chat message send-by-webhook --token <robotToken> \
--title "告警" --text "CPU 使用率超过 90%" --format json
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785
Flags:
--token string 自定义机器人 Webhook Token (必填)
--title string 消息标题 (必填)
--at-all @ 所有人
--at-mobiles string @ 指定手机号,逗号分隔
--at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId)
--text string 消息内容 (必填)
--at-all @所有人
--at-mobiles string @指定手机号列表,逗号分隔
--at-users string @指定用户 ID 列表,逗号分隔
--title string 消息标题 (必填)
--token string Webhook Token (必填)
```
---
## 意图判断
- 用户说"搜索一个群" → `search`
- 用户说"帮我建个群" → `group create`
- 用户说"看看群里有谁" → `group members list`
- 用户说"把张三拉进群" → 先 `contact user search` 获取 userId,再 `group members add`
- 用户说"把张三移出群" → 先 `contact user search` 获取 userId,再 `group members remove`(⚠️ 需确认)
- 用户说"改一下群名" → `group rename`
- 用户说"让机器人在群里发通知" → `message send-by-bot --group`
- 用户说"机器人给张三发消息" → 先 `contact user search` 获取 userId,再 `message send-by-bot --users`
- 用户说"通过 Webhook 发告警" / 用户有 Webhook Token → `message send-by-webhook`
- 用户说"撤回机器人消息" → `message recall-by-bot`
- 用户说"查一下我的机器人" → `bot search`
- 用户说"把机器人加到群里" → `group members add-bot`
用户说"建群/创建群聊" → `chat group create`
用户说"搜索群/找群" → `chat search`
用户说"群成员/看群里有谁" → `chat group members`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时)
用户说"机器人发消息/机器人群发" → `chat message send-by-bot`
用户说"机器人撤回消息" → `chat message recall-by-bot`
用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook`
用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies`
用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all`
用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused`
用户说"查看我的机器人" → `chat bot search`
用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search`
用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common`
用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations`
用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info`
**关键区分**: `send-by-bot`(企业内部机器人,需 robotCode) vs `send-by-webhook`(自定义机器人 Webhook,需 token)
关键区分:
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令
- `chat message list-topic-replies` — 拉取群话题的回复消息列表
- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页
- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页
- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息
- `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 conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等)
- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot
## 核心工作流
```bash
# ── 工作流: 建群并添加机器人 ──
# 1. 搜索群 — 提取 openconversation_id
dws chat search --query "项目冲刺" --format json
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
# 2. 拉取群消息
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --format json
# 2. 创建群
dws chat group create --name "项目群" --users <userId1>,<userId2> --format json
# 2b. 拉取未读会话列表
dws chat message list-unread-conversations --count 20 --format json
# 3. 搜索机器人
dws chat bot search --format json
# 3. 以个人身份发送群消息
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
# 4. 添加机器人到群
dws chat group members add-bot --id <openConversationId> --robot-code <code> --format json
# 4. 以个人身份单聊(通过 userId)
dws chat message send --user <userId> "你好" --format json
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
# 5. 机器人发群消息(Markdown)
dws chat message send-by-bot --robot-code <robot-code> \
--group <openconversation_id> --title "日报" --text "## 今日完成..." --format json
# 6. 机器人单聊发消息
dws chat message send-by-bot --robot-code <robot-code> \
--users userId1,userId2 --title "提醒" --text "请提交周报" --format json
# 7. Webhook 发告警
dws chat message send-by-webhook --token <webhook-token> \
--title "告警" --text "CPU 超 90%" --at-all --format json
```
```bash
# ── 工作流: 机器人群发消息 ──
## 复合工作流
# 1. 搜索可用机器人
### 机器人发消息后撤回(完整流程)
撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。
```bash
# Step 1: 查我的机器人 — 提取 robot-code
dws chat bot search --format json
# 2. 发送群消息
dws chat message send-by-bot --robot-code <code> --group <groupId> \
# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "通知" --text "内容" --format json
# Step 3: 用同一个 robot-code + processQueryKey 撤回
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> \
--keys <processQueryKey> --format json
```
```bash
# ── 工作流: Webhook 告警 ──
### 将已有机器人加入群并发消息(完整流程)
# 直接通过 Webhook Token 发送
dws chat message send-by-webhook --token <token> \
--title "告警" --text "服务异常" --at-all --format json
机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。
```bash
# Step 1: 搜索我的机器人 — 提取 robotCode
dws chat bot search --name "项目提醒" --format json
# Step 2: 搜索群 — 提取 openConversationId
dws chat search --query "项目群" --format json
# Step 3: 将机器人添加到群(需当前用户对该群有管理权限)
dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json
# Step 4: 机器人发消息
dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> \
--title "提醒" --text "请及时更新项目状态" --format json
```
### 机器人 @指定人发群消息
`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。
```bash
# Step 1: 搜人获取 userId
dws aisearch person --keyword "张三" --dimension name --format json
# Step 2: 取 userId 发送(注意 text 中的占位符)
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json
```
### 发送图片/文件消息(跨产品: drive → chat)
```bash
# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证
dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json
# Step 2: HTTP PUT 上传文件到 OSS
curl -X PUT -T "截图.png" "<upload-info 返回的上传 URL>"
# Step 3: 提交上传 — 获取 dentryUuid
dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id <uploadId> --format json
# Step 4: 获取下载链接
dws drive download --file-id <dentryUuid> --format json
# Step 5: 用 Markdown 图片语法发送
dws chat message send --group <openconversation_id> \
--text "![截图](下载链接)" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `search` | openConversationId | `group members` / `group rename` / `group members add` / `send-by-bot --group` |
| `group create` | openConversationId | 同上 |
| `bot search` | robotCode | `send-by-bot` / `recall-by-bot` / `add-bot` |
| `message send-by-bot` | processQueryKey | `recall-by-bot --keys` |
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `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 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 图片/链接语法 |
## 注意事项
- `--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 list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话
- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑
- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
+6 -7
View File
@@ -17,9 +17,9 @@ Example:
Usage:
dws contact user search [flags]
Example:
dws contact user search --keyword "张三"
dws contact user search --query "张三"
Flags:
--keyword string 搜索关键词 (必填)
--query string 搜索关键词 (必填)
```
#### 按手机号搜索用户
@@ -49,9 +49,9 @@ Flags:
Usage:
dws contact dept search [flags]
Example:
dws contact dept search --keyword "技术部"
dws contact dept search --query "技术部"
Flags:
--keyword string 搜索关键词 (必填)
--query string 搜索关键词 (必填)
```
#### 查看部门成员
@@ -79,10 +79,10 @@ Flags:
dws contact user get-self --format json
# 2. 按名字搜索同事 — 提取 userId
dws contact user search --keyword "张三" --format json
dws contact user search --query "张三" --format json
# 3. 查看部门结构 — 提取 deptId
dws contact dept search --keyword "技术部" --format json
dws contact dept search --query "技术部" --format json
# 4. 查看部门成员
dws contact dept list-members --ids <deptId> --format json
@@ -93,7 +93,6 @@ dws contact dept list-members --ids <deptId> --format json
| 操作 | 提取 | 用于 |
|------|------|------|
| `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 |
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `dept search` | `deptId` | dept list-members 的 --ids |
## 注意事项
+48
View File
@@ -0,0 +1,48 @@
# 开放平台文档 (devdoc) 命令参考
搜索钉钉**开放平台**开发文档,用于回答开发者关于 OpenAPI、字段、错误码、接入指南、配额等技术问题。
## 命令总览
### 搜索开发文档
```
Usage:
dws devdoc article search [flags]
Example:
dws devdoc article search --query "OAuth2 接入"
dws devdoc article search --query "消息卡片" --page 2 --size 5
dws devdoc article search --query "机器人" --size 10
Flags:
--query string 搜索关键词 (必填)
--page int 分页页码 (从 1 开始,默认 1)
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为 `--query`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
## 核心工作流
```bash
# 开发者问"OAuth2 怎么接"
dws devdoc article search --query "OAuth2 接入" --format json
# 命中结果多时翻页
dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
```
## 注意事项
- `--query` 必填;建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
+122 -7
View File
@@ -80,6 +80,22 @@ Flags:
--markdown string 文档初始 Markdown 内容
```
### 创建其他类型文件 (表格/脑图/白板/多维表/画板)
```
Usage:
dws doc file create [flags]
Example:
dws doc file create --name "项目周报" --type adoc
dws doc file create --name "数据统计" --type axls --folder <FOLDER_ID>
dws doc file create --name "思维导图" --type amind --workspace <WS_ID>
dws doc file create --name "子文件夹" --type folder
Flags:
--name string 文件名称 (必填)
--type string 文件类型 (必填): adoc=文档, axls=表格, appt=演示, adraw=白板, amind=脑图, able=多维表, folder=文件夹
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID 或 URL
```
### 更新文档内容
```
Usage:
@@ -135,6 +151,45 @@ Flags:
--workspace string 目标知识库 ID
```
### 复制文档/文件
```
Usage:
dws doc copy [flags]
Example:
dws doc copy --node <DOC_ID> --folder <TARGET_FOLDER_ID>
dws doc copy --node <DOC_ID> --workspace <TARGET_WS_ID>
dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder <FOLDER_ID>
Flags:
--node string 源文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录)
```
### 移动文档/文件
```
Usage:
dws doc move [flags]
Example:
dws doc move --node <DOC_ID> --folder <TARGET_FOLDER_ID>
dws doc move --node <DOC_ID> --workspace <TARGET_WS_ID>
Flags:
--node string 源文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录)
```
### 重命名文档/文件
```
Usage:
dws doc rename [flags]
Example:
dws doc rename --node <DOC_ID> --name "新名称"
dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --name "项目周报 v2"
Flags:
--node string 文档/文件 ID 或 URL (必填)
--name string 新名称 (必填)
```
### 查询块元素
```
Usage:
@@ -229,6 +284,24 @@ Flags:
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
### 创建划词评论 (内联评论)
```
Usage:
dws doc comment create-inline [flags]
Example:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--block-id string 评论锚定所在的块 ID (必填,可通过 dws doc block list 获取)
--start int 选中文本在块内的起始字符偏移量,从 0 开始 (必填)
--end int 选中文本在块内的结束字符偏移量,必须大于 start (必填)
--content string 评论的文字内容,纯文本 (必填)
--selected-text string 选中文本内容,填写后评论列表会展示「引用原文:xxx」
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
### 回复文档评论
```
Usage:
@@ -282,12 +355,24 @@ Flags:
- 元信息 → `info`
用户说"写文档/创建文档":
- 新建 → `create`
- 新建纯文档 (adoc) → `create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
用户说"新建表格/脑图/白板/多维表/演示文稿":
- 用 `file create --type` 指定类型 (axls/amind/adraw/able/appt/adraw)
用户说"建文件夹/新建目录":
- 创建 → `folder create`
- 创建 → `folder create` 或 `file create --type folder`
用户说"复制文档/拷贝一份":
- 复制 → `copy` (需源 --node 和目标 --folder/--workspace)
用户说"移动文档/换个目录":
- 移动 → `move` (需源 --node 和目标 --folder/--workspace)
用户说"改文档名字/重命名":
- 改名 → `rename` (需 --node 和新 --name)
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `upload`(需本地文件路径)
@@ -390,7 +475,7 @@ dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
# 1. 查看文档的所有评论
dws doc comment list --node <DOC_ID> --format json
# 2. 在文档上创建评论
# 2. 在文档上创建全文评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 3. 创建评论并 @ 相关人
@@ -398,11 +483,34 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
# 再将 userId 传入 --mention
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
# 4. 对某段文字创建划词评论(需先 block list 拿 blockId 和字符偏移)
dws doc block list --node <DOC_ID> --format json
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 12 \
--content "这里的数据要复核" --selected-text "被选中的原文片段" --format json
# 5. 回复某条评论(commentKey 从 list 或 create 返回中获取)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 5. 用表情回复评论
# 6. 用表情回复评论
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# ── 工作流 8: 创建非文档类型文件 ──
# 创建表格 / 脑图 / 白板 / 多维表 / 演示文稿
dws doc file create --name "销售数据" --type axls --folder <FOLDER_ID> --format json
dws doc file create --name "需求脑图" --type amind --workspace <WS_ID> --format json
dws doc file create --name "Q1 立项会" --type appt --format json
# ── 工作流 9: 整理文档结构 (复制 / 移动 / 重命名) ──
# 1. 把模板复制到目标目录作为新工作件
dws doc copy --node <TEMPLATE_DOC_ID> --folder <TARGET_FOLDER_ID> --format json
# 2. 把文档从个人空间挪到团队知识库
dws doc move --node <DOC_ID> --workspace <WS_ID> --format json
# 3. 重命名
dws doc rename --node <DOC_ID> --name "项目周报 v2" --format json
```
## 上下文传递表
@@ -418,8 +526,11 @@ dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
| `download` | 本地文件路径 | 下载后的文件保存位置 |
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
| `comment create` | `commentKey` | comment reply 的 --comment-key |
| `contact user search` | `userId` | comment create/reply 的 --mention |
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
## nodeId 双格式说明
@@ -448,6 +559,10 @@ dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE
- `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档
- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作
- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录
- `create` 只能建"文档"(adoc);要建表格/脑图/白板/多维表/演示/文件夹,用 `file create --type`
- `copy` 需要对源节点有"阅读"权限、对目标目录有"编辑"权限;`move` 需要对源节点有"管理"权限
- `copy` / `move` 不传 `--folder` 时,`--workspace` 表示放到知识库根目录;两者都不传则回落到"我的文档"
- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始)
## 自动化脚本
+177
View File
@@ -0,0 +1,177 @@
# 钉盘 (drive) 命令参考
钉盘 = DingTalk Drive,用于云端文件存储 / 上传 / 下载 / 目录管理。不是在线文档编辑;要编辑文档请用 [doc](./doc.md)。
## 命令总览
### 列出钉盘目录
```
Usage:
dws drive list [flags]
Example:
dws drive list
dws drive list --parent-id <FOLDER_ID>
dws drive list --parent-id <FOLDER_ID> --max 20 --order-by modifiedTime --order desc
Flags:
--parent-id string 父目录 ID (不传则列根目录)
--space-id string 钉盘空间 ID (一般无需指定)
--max int 每页数量 (默认 20)
--next-token string 分页游标 (从上次结果的 nextToken 获取)
--order-by string 排序字段: name / createdTime / modifiedTime
--order string 排序方向: asc / desc
--thumbnail 是否返回缩略图链接
```
### 获取文件元信息
```
Usage:
dws drive info [flags]
Example:
dws drive info --file-id <FILE_ID>
Flags:
--file-id string 文件或文件夹 ID (必填)
--space-id string 钉盘空间 ID (一般无需指定)
```
### 创建文件夹
```
Usage:
dws drive mkdir [flags]
Example:
dws drive mkdir --name "项目资料"
dws drive mkdir --name "子目录" --parent-id <PARENT_FOLDER_ID>
Flags:
--name string 文件夹名称 (必填)
--parent-id string 父目录 ID (不传则建在根目录)
--space-id string 钉盘空间 ID (一般无需指定)
```
### 获取下载临时链接
```
Usage:
dws drive download [flags]
Example:
dws drive download --file-id <FILE_ID>
Flags:
--file-id string 文件 ID (必填)
--space-id string 钉盘空间 ID (一般无需指定)
```
### 获取上传凭证 (上传第一步)
```
Usage:
dws drive upload-info [flags]
Example:
dws drive upload-info --file-name "report.pdf" --file-size 102400
dws drive upload-info --file-name "slides.pptx" --file-size 512000 --parent-id <FOLDER_ID>
Flags:
--file-name string 文件名含后缀 (必填)
--file-size int 文件大小,单位字节 (必填)
--mime-type string MIME 类型 (可选,服务端会自动推断)
--parent-id string 父目录 ID (不传则上传到根目录)
--space-id string 钉盘空间 ID (一般无需指定)
```
### 提交上传 (上传第三步)
```
Usage:
dws drive commit [flags]
Example:
dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id <UPLOAD_ID>
Flags:
--file-name string 文件名含后缀 (必填,须与 upload-info 一致)
--file-size int 文件大小,单位字节 (必填,须与 upload-info 一致)
--upload-id string upload-info 返回的 uploadId (必填)
--parent-id string 父目录 ID (必填时须与 upload-info 一致)
--space-id string 钉盘空间 ID (一般无需指定)
--conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME)
```
## 意图判断
用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list`
用户说"钉盘文件详情/文件信息" → `info` (需 fileId)
用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir`
用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET
用户说"上传文件到钉盘/把本地文件传钉盘":
- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit`
- **没有**一条聚合命令可以完成;必须完整走完三步
关键区分:
- drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容)
- 把图片/文件发到群里一般走 drive 上传拿链接 → chat 发送 Markdown 链接 (见 [chat.md](./chat.md) 的 `drive → chat` 工作流)
## 核心工作流
```bash
# ── 工作流 1: 浏览钉盘 ──
# 1. 看根目录
dws drive list --format json
# 2. 进入子目录 (parentId 取自上一步的 dentryUuid)
dws drive list --parent-id <FOLDER_ID> --format json
# 3. 看单个文件的元信息
dws drive info --file-id <FILE_ID> --format json
# ── 工作流 2: 上传本地文件到钉盘 (三步不能省) ──
# Step 1: 拿上传凭证
dws drive upload-info --file-name "report.pdf" --file-size 102400 --parent-id <FOLDER_ID> --format json
# → 返回: resourceUrl (预签名 URL), headers, uploadId
# Step 2: Agent 自己发 HTTP PUT 把文件二进制推到 resourceUrl
# 请求头必须携带返回的 headers 全部键值对;期望 HTTP 200
curl -X PUT -T ./report.pdf -H "<header-from-step-1>: <value>" "<resourceUrl>"
# Step 3: 提交入库
dws drive commit --file-name "report.pdf" --file-size 102400 --upload-id <UPLOAD_ID> \
--parent-id <FOLDER_ID> --format json
# → 返回: dentryUuid (= 后续 download/info 用的 fileId)
# ── 工作流 3: 下载钉盘文件到本地 (两步) ──
# Step 1: 拿临时下载 URL
dws drive download --file-id <FILE_ID> --format json
# → 返回: resourceUrl (带签名的临时下载 URL), expirationSeconds
# Step 2: Agent 自己发 HTTP GET 下载二进制
curl -o ./report.pdf "<resourceUrl>"
# ── 工作流 4: 建目录后批量上传 ──
# 1. 建目录 → 拿 folderId
dws drive mkdir --name "2026 Q1 归档" --format json
# 2. 再走 "工作流 2" 把每个文件上传到该目录
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | `dentryUuid` (folder 类) | 下次 list 的 --parent-id、upload-info / commit 的 --parent-id、mkdir 的 --parent-id |
| `list` | `dentryUuid` (file 类) | info / download 的 --file-id |
| `list` | `nextToken` | 下次 list 的 --next-token |
| `mkdir` | `dentryUuid` | 作为父目录传给 upload-info / commit 的 --parent-id |
| `upload-info` | `resourceUrl` + `headers` | Agent 自行执行 HTTP PUT 上传二进制 |
| `upload-info` | `uploadId` | commit 的 --upload-id |
| `commit` | `dentryUuid` | download / info / chat message 发送图片链接的 --file-id |
| `download` | `resourceUrl` | Agent 自行执行 HTTP GET 下载;也可作为 Markdown 图片/附件链接发送到 chat |
## 注意事项
- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败
- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit`
- 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info`
- `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET
- `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配
- `--conflict-handler` 默认 `AUTO_RENAME` (自动重命名);`OVERWRITE` 覆盖同名文件前必须和用户确认
- `--space-id` 绝大多数场景不需要传;默认会用用户主钉盘空间
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
## 相关产品
- [doc](./doc.md) — 钉钉在线文档 / 知识库(文字、表格、脑图等富文本节点),和 drive 的裸文件存储不同
- [chat](./chat.md) — 结合 drive 发送图片 / 附件消息到群聊(Markdown 链接语法)
+138 -43
View File
@@ -1,5 +1,7 @@
# AI听记 (minutes) 命令参考
AI 听记:列表 / 详情 / 摘要 / 待办 / 文字稿 / 思维导图 / 发言人 / 热词 / 文件上传。
## 命令总览
### 查询我创建的听记列表
@@ -143,53 +145,126 @@ Flags:
--title string 新标题 (必填)
```
### 发起听记(开始录音)
### 覆盖听记 AI 摘要
```
Usage:
dws minutes record start [flags]
dws minutes update summary [flags]
Example:
dws minutes record start
dws minutes record start --session-id <sessionId>
dws minutes update summary --id <taskUuid> --content "修订后的摘要 Markdown"
Flags:
--session-id string AI 助理会话 ID (可选)
--id string 听记 taskUuid (必填)
--content string 新的摘要正文 (必填),会整体覆盖原摘要
```
### 暂停听记录音
将 AI 生成的摘要替换成定制版本,适合人工修订或按业务口径重写后回写。
### 全文替换听记文本
```
Usage:
dws minutes record pause [flags]
dws minutes replace-text [flags]
Example:
dws minutes record pause --id <taskUuid>
dws minutes record pause --id <taskUuid> --session-id <sessionId>
dws minutes replace-text --id <taskUuid> --search "小钉" --replace "DingTalk"
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
--id string 听记 taskUuid (必填)
--search string 需要替换的原文 (必填)
--replace string 替换后的文本 (必填)
```
### 恢复听记录音
在转写段落与摘要中一次性替换所有命中文本,适合纠正系统性的识别错误(如专有名词)。
### 替换发言人标签
```
Usage:
dws minutes record resume [flags]
dws minutes speaker replace [flags]
Example:
dws minutes record resume --id <taskUuid>
dws minutes record resume --id <taskUuid> --session-id <sessionId>
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三"
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三" --target-uid <userId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
--id string 听记 taskUuid (必填)
--from string 原发言人昵称,如 "说话人1" (必填)
--to string 新发言人昵称 (必填)
--target-uid string 绑定到指定钉钉 userId (可选)
```
### 结束听记录音
修正自动分离的发言人标签,可顺带把该说话人对应到真实用户。
### 添加个人热词
```
Usage:
dws minutes record stop [flags]
dws minutes hot-word add [flags]
Example:
dws minutes record stop --id <taskUuid>
dws minutes record stop --id <taskUuid> --session-id <sessionId>
dws minutes hot-word add --words "钉钉,悟空,DWS"
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
--words string 热词列表,逗号分隔 (必填)
```
把专有名词/业务术语写入个人热词库,后续听记的语音识别会优先匹配,用来兜底高频误识别。
### 生成思维导图
```
Usage:
dws minutes mind-graph create [flags]
Example:
dws minutes mind-graph create --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填)
```
异步任务:提交后返回 jobId,需要用 `mind-graph status` 轮询结果。
### 查询思维导图状态
```
Usage:
dws minutes mind-graph status [flags]
Example:
dws minutes mind-graph status --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填)
```
轮询 `mind-graph create` 的生成任务,就绪后返回思维导图内容。
### 创建文件上传会话
```
Usage:
dws minutes upload create [flags]
Example:
dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘"
Flags:
--file-name string 本地文件名 (必填)
--file-size string 文件大小,单位字节 (必填)
--title string 听记标题 (可选)
--template-id string 摘要模板 ID (可选)
--input-language string 语言,如 zh_CN / en_US (可选)
--enable-message-card string 是否推送消息卡片: true/false (可选)
```
三步上传的第 1 步:返回 sessionId 和预签名 URL,由调用方把本地音视频文件 HTTP PUT 到该 URL。
### 完成上传并生成听记
```
Usage:
dws minutes upload complete [flags]
Example:
dws minutes upload complete --session-id <sessionId>
Flags:
--session-id string upload create 返回的 sessionId (必填)
```
三步上传的第 3 步:通知服务端文件已上传完毕,触发转写与 AI 处理,返回新的 taskUuid。
### 取消上传
```
Usage:
dws minutes upload cancel [flags]
Example:
dws minutes upload cancel --session-id <sessionId>
Flags:
--session-id string upload create 返回的 sessionId (必填)
```
上传中途放弃或上游出错时释放会话,避免残留。
## 意图判断
用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选)
@@ -197,23 +272,23 @@ Flags:
用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选)
用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数
用户说"听记详情/听记信息" → `get info`
用户说"批量看听记/一次查多篇" → `get batch`(`--ids` 传逗号分隔 taskUuid)
用户说"摘要/总结/会议纪要" → `get summary`
用户说"关键字/关键词" → `get keywords`
用户说"原文/转写/录音文字" → `get transcription`
用户说"会议待办/听记待办" → `get todos`
用户说"改听记标题/重命名听记" → `update title`
用户说"发起听记/开始录音" → `record start`
用户说"暂停听记/暂停录音" → `record pause`
用户说"继续听记/恢复录音" → `record resume`
用户说"结束听记/结束录音" → `record stop`
用户说"改摘要/覆盖摘要/重写总结" → `update summary`
用户说"全文替换/批量改转写里的错字/专有名词搞错了" → `replace-text`
用户说"改发言人/把说话人1改成张三/认领发言人" → `speaker replace`
用户说"加热词/让后续识别更准/把某个专业术语教给系统" → `hot-word add`
用户说"生成思维导图/导图/脑图" → `mind-graph create`(异步),再 `mind-graph status` 轮询
用户说"上传录音生成听记/把本地音视频变听记" → `upload create` → 本地 PUT → `upload complete`;放弃则 `upload cancel`
用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作
## 核心工作流
```bash
# 0. 发起听记(开始录音)
dws minutes record start --format json
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --next-token <nextToken> --format json
@@ -236,13 +311,28 @@ dws minutes get transcription --id <taskUuid> --format json
# 4. 提取待办事项
dws minutes get todos --id <taskUuid> --format json
# 5. 修改标题
dws minutes update title --id <taskUuid> --title "新标题" --format json
# 5. 批量补齐多个 taskUuid 的详情
dws minutes get batch --ids <taskUuid1>,<taskUuid2>,<taskUuid3> --format json
# 6. 录音控制(基于 start 返回的 taskUuid)
dws minutes record pause --id <taskUuid> --format json
dws minutes record resume --id <taskUuid> --format json
dws minutes record stop --id <taskUuid> --format json
# 6. 修改标题 / 覆盖摘要
dws minutes update title --id <taskUuid> --title "新标题" --format json
dws minutes update summary --id <taskUuid> --content "修订后的摘要 Markdown" --format json
# 7. 修正转写错字 / 发言人标签 / 加热词
dws minutes replace-text --id <taskUuid> --search "小钉" --replace "DingTalk" --format json
dws minutes speaker replace --id <taskUuid> --from "说话人1" --to "张三" --format json
dws minutes hot-word add --words "钉钉,悟空,DWS" --format json
# 8. 异步生成思维导图(create 拿 jobId,status 轮询)
dws minutes mind-graph create --id <taskUuid> --format json
dws minutes mind-graph status --id <taskUuid> --format json
# 9. 从本地音视频上传生成听记(三步走)
dws minutes upload create --file-name "会议录音.m4a" --file-size 10485760 --title "Q2 复盘" --format json
# → 拿到 sessionId 和预签名 URL,调用方自行 HTTP PUT 把文件推上去
dws minutes upload complete --session-id <sessionId> --format json
# 放弃上传
dws minutes upload cancel --session-id <sessionId> --format json
```
## 上下文传递表
@@ -253,18 +343,23 @@ dws minutes record stop --id <taskUuid> --format json
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
| `upload create` | `sessionId`、预签名 URL | 本地 PUT 上传 + `upload complete` / `upload cancel` 的 --session-id |
| `upload complete` | 新听记 `taskUuid` | 后续 get/update/mind-graph 的 --id |
| `mind-graph create` | 异步 jobId(随 taskUuid 关联) | `mind-graph status` 的 --id 查进度与结果 |
## 注意事项
- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参
- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid`
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
- `taskUuid` 是听记的唯一标识,所有 get/update/mind-graph/speaker/replace-text 操作均以此为入参
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`);三者均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
- `get info` 是单条查询;`get batch` 是批量补齐 ID 列表(`--ids` 逗号分隔),用在"有一串 taskUuid,想一次拿标题/时长/参与人"的场景
- `get summary` 返回 AI 生成的结构化 Markdown 摘要;`update summary` 会整体覆盖原摘要,不做增量合并
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序
- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid
- `replace-text` 是系统性纠错:命中即替换,整篇转写与摘要一起改,慎用模糊文本
- `speaker replace` 只改"标签",不重跑说话人分离;`--target-uid` 可顺带把该说话人绑定到真实 userId
- `hot-word add` 作用于**个人**热词库,只对本人后续的听记识别生效
- `mind-graph create` 是异步:调用后立即返回,需要用 `mind-graph status --id <taskUuid>` 轮询到 ready 才能拿到图
- 上传流程严格三步:`upload create`(拿 sessionId + 预签名 URL)→ 客户端自己 HTTP PUT 文件 → `upload complete`(触发转写);中途放弃必须用 `upload cancel` 释放会话,不要只丢 sessionId
## 自动化脚本
+199
View File
@@ -0,0 +1,199 @@
# OA 审批 (oa) 命令参考
钉钉 OA 审批:待办审批列表 / 查看详情 / 同意 / 拒绝 / 撤销 / 流程记录 / 表单模板。
## 命令总览
### 查询待我处理的审批实例
```
Usage:
dws oa approval list-pending [flags]
Example:
dws oa approval list-pending
dws oa approval list-pending --page 1 --size 20
dws oa approval list-pending --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
Flags:
--page string 页码 (默认 1)
--size string 每页数量 (默认 10)
--start string 提交开始时间 ISO-8601 (可选)
--end string 提交结束时间 ISO-8601 (可选)
```
"待办收件箱"视图:列出当前用户仍需动作的审批 `processInstanceId`。注意返回的是**实例 ID**,真正驱动 approve/reject 还需要用 `tasks` 再换成 `taskId`。
### 查询我发起的审批实例
```
Usage:
dws oa approval list-initiated [flags]
Example:
dws oa approval list-initiated
dws oa approval list-initiated --process-code <processCode> --max-results 20
dws oa approval list-initiated --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --next-token <nextToken>
Flags:
--process-code string 按指定表单/流程模板过滤 (可选)
--start string 发起开始时间 ISO-8601 (可选)
--end string 发起结束时间 ISO-8601 (可选)
--max-results string 每页条数 (可选)
--next-token string 分页 token (首页留空)
```
查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。
### 查询可发起的审批表单模板
```
Usage:
dws oa approval list-forms [flags]
Example:
dws oa approval list-forms
dws oa approval list-forms --cursor 0 --size 20
Flags:
--cursor string 分页游标,首次传 0 (默认 0)
--size string 每页大小 (可选)
```
列出当前用户被授权发起的审批表单(如"请假"、"报销"),返回 `processCode`,后续用于提交新的申请或按模板筛选历史实例。
### 获取审批实例详情
```
Usage:
dws oa approval detail [flags]
Example:
dws oa approval detail --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
拉取单个实例的完整内容:表单字段、附件、当前状态、参与人。适合让 AI 读懂审批单再决定动作或生成摘要。
### 查询审批实例的操作记录
```
Usage:
dws oa approval records [flags]
Example:
dws oa approval records --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
列出实例的流转历史(谁在什么时候同意/加签/转交/评论),用于复盘或解释"这单卡在哪"。
### 查询实例下待处理的任务
```
Usage:
dws oa approval tasks [flags]
Example:
dws oa approval tasks --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 processInstanceId (必填)
```
针对具体实例拿到当前用户名下的 `taskId`。approve / reject 要的是 `taskId`,不是 `processInstanceId`,这一步不能省。
### 同意审批
```
Usage:
dws oa approval approve [flags]
Example:
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId>
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId> --remark "同意,按流程推进"
Flags:
--instance-id string 审批实例 processInstanceId (必填)
--task-id string 待办任务 taskId (必填),来自 approval tasks
--remark string 审批意见 (可选)
```
以当前用户身份同意某个审批任务。`--task-id` 必须是当前用户的待办 taskId,否则 MCP 会拒绝。
### 拒绝审批
```
Usage:
dws oa approval reject [flags]
Example:
dws oa approval reject --instance-id <processInstanceId> --task-id <taskId> --remark "预算不足,下月再议"
Flags:
--instance-id string 审批实例 processInstanceId (必填)
--task-id string 待办任务 taskId (必填),来自 approval tasks
--remark string 拒绝理由 (可选,但强烈建议填)
```
以当前用户身份驳回审批任务;拒绝后一般由 MCP 端决定流程是打回申请人还是整体终止。
### 撤回已发起的审批
```
Usage:
dws oa approval revoke [flags]
Example:
dws oa approval revoke --instance-id <processInstanceId>
dws oa approval revoke --instance-id <processInstanceId> --remark "金额填错,重新提交" --yes
Flags:
--instance-id string 审批实例 processInstanceId (必填)
--remark string 撤回原因 (可选)
```
只能撤销**自己发起且尚未终态**的实例。命令标了 `isSensitive`,AI 调用前建议与用户再确认一次,可配合全局 `--yes` 跳过确认。
## 意图判断
用户说"我要审的单/待我处理的审批/审批收件箱" → `list-pending` 拿 `processInstanceId`
用户说"这单我同意/批了它/通过" → `approval tasks` 换 `taskId` → `approval approve`
用户说"这单不行/驳回/拒绝" → `approval tasks` 换 `taskId` → `approval reject --remark "<理由>"`
用户说"这单详情/看看内容/这单在说什么" → `approval detail`
用户说"这单卡在谁那/谁还没审/流程走到哪" → `approval records`
用户说"我发起的审批/我的申请" → `list-initiated`(可用 `--process-code` 按模板筛)
用户说"能发起哪些审批/有哪些表单" → `list-forms`
用户说"撤回我的申请/我不提了" → `approval revoke`(仅限自己发起且未终态)
关键区分: oa(钉钉 OA 审批流程) vs todo(个人待办) vs report(日志汇报)
## 核心工作流
```bash
# 1. 看我有哪些待审的单 — 提取 processInstanceId
dws oa approval list-pending --page 1 --size 20 --format json
# 2. 看单子内容(表单字段 / 附件 / 当前状态)
dws oa approval detail --instance-id <processInstanceId> --format json
# 3. 想动作(同意/拒绝)前,先拿 taskId
dws oa approval tasks --instance-id <processInstanceId> --format json
# 4. 同意 / 拒绝(taskId 来自步骤 3)
dws oa approval approve --instance-id <processInstanceId> --task-id <taskId> --remark "同意" --format json
dws oa approval reject --instance-id <processInstanceId> --task-id <taskId> --remark "预算不足" --format json
# 5. 查看流程记录 — 谁何时做了什么
dws oa approval records --instance-id <processInstanceId> --format json
# 6. 查我发起的审批 / 按模板过滤
dws oa approval list-initiated --format json
dws oa approval list-initiated --process-code <processCode> --format json
# 7. 能发起哪些表单
dws oa approval list-forms --cursor 0 --size 20 --format json
# 8. 撤回我发起的单
dws oa approval revoke --instance-id <processInstanceId> --remark "填错了" --yes --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list-pending` | `processInstanceId`、`nextToken` | detail / tasks / records 的 --instance-id |
| `list-initiated` | `processInstanceId`、`nextToken` | detail / records / revoke 的 --instance-id |
| `list-forms` | `processCode` | `list-initiated --process-code` 按模板过滤,或后续发起新审批 |
| `approval tasks` | `taskId` | approve / reject 的 --task-id |
| `approval detail` | 表单字段、参与人、当前状态 | 供 AI 决策是否批准、生成摘要 |
## 注意事项
- `processInstanceId`(实例 ID)与 `taskId`(待办任务 ID)**不是一回事**,不能混用:
- `list-pending` / `list-initiated` / `detail` / `records` / `revoke` 用 `processInstanceId`
- `approve` / `reject` 用 `taskId`,必须先 `approval tasks --instance-id <processInstanceId>` 换一次
- `list-pending` 是"需要我动作"视图;同一个实例如果流转到下一节点就会从这里消失
- `list-initiated` 是"我发起的"视图;和 `list-pending` 是两套语义,不要互相替代
- `--start` / `--end` 使用 ISO-8601 格式,内部会自动转毫秒时间戳
- `revoke` 只能撤销**当前用户发起且未完结**的实例;已流转结束、已作废、或不是自己发起的都会失败
- `approve` / `reject` 的 `--remark` 可选但强烈建议填,尤其是 `reject`——审批流下游会把理由展示给申请人
- 所有敏感动作(`approve` / `reject` / `revoke`)在 AI 调用时应与用户二次确认,可用全局 `--yes` 跳过确认
- `list-forms` 返回的 `processCode` 是未来发起新审批、或筛选历史实例的钩子;目前本命令集只覆盖审批的"处理侧",发起新审批需走业务方自己的流程
+4 -2
View File
@@ -84,6 +84,8 @@ Flags:
--report-id string 日志 ID (必填)
```
返回该日志的互动聚合数据:浏览数 (views)、点赞数 (likes)、评论数 (comments) 等,用于衡量一篇日志的阅读与反馈情况。
### 查询当前人创建的日志列表
```
Usage:
@@ -106,7 +108,7 @@ Flags:
用户说"查日志/看日报" → `list` 获取列表,再 `detail`
用户说"写日报/提交周报/发日志/填日志" → 先 `template list` / `template detail` 取 `templateId` 与各控件 `key`/`sort`/类型,拼 `--contents` JSON,再 `create`
用户说"日志统计/已读统计" → `stats`
用户说"日志数据/互动情况/多少人看了/点赞评论" → `stats`
用户说"有什么日志模版" → `template list` 或 `template detail`
用户说"我发过的日志/我创建的日志" → `sent`
@@ -133,7 +135,7 @@ dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+0
# 4. 查看日志详情
dws report detail --report-id <reportId> --format json
# 5. 查看日志统计(已读/未读)
# 5. 查看日志统计(浏览/点赞/评论聚合)
dws report stats --report-id <reportId> --format json
# 6. 查看当前人创建的日志列表
+9 -4
View File
@@ -9,11 +9,15 @@ Usage:
Example:
dws todo task create --title "修复线上Bug" --executors <USER_ID_1>,<USER_ID_2> --priority 40
dws todo task create --title "提交报告" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00"
dws todo task create --title "每日站会" --executors <USER_ID> \
--due "2026-03-20T10:00:00+08:00" \
--recurrence $'DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1'
Flags:
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--title string 待办标题 (必填)
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--recurrence string 循环规则 RFC 5545 (DTSTART+RRULE,仅在同时设置 --due 时生效)
--title string 待办标题 (必填)
```
### 查询待办列表
@@ -122,6 +126,7 @@ dws todo task delete --task-id <taskId> --yes --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 截止时间使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- `--recurrence` 为 RFC 5545 循环规则(`DTSTART:...\nRRULE:FREQ=DAILY;INTERVAL=1` 这种格式),必须与 `--due` 同时设置才生效
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态