Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b101d5b12b | ||
|
|
acc5dc8a3c | ||
|
|
e3b0c9b1ed | ||
|
|
cc1f0a47e3 | ||
|
|
b0c9160ed5 | ||
|
|
0b544a86b7 | ||
|
|
ad33a46883 | ||
|
|
bbca507792 | ||
|
|
97390829dd | ||
|
|
7828089081 | ||
|
|
af000a8dfa | ||
|
|
f19a3ccfa5 | ||
|
|
89c5038446 | ||
|
|
4f915e4e2c | ||
|
|
ec03b7cca3 | ||
|
|
e0544579d2 | ||
|
|
ce43280c11 | ||
|
|
74ca40c197 | ||
|
|
cfaa673863 | ||
|
|
3bc6c31a2d | ||
|
|
383aeefaf6 | ||
|
|
a5bede3a19 | ||
|
|
bbf66e23d6 | ||
|
|
5e168c92cf | ||
|
|
725577103d | ||
|
|
f762117d4e | ||
|
|
750b6c04d6 | ||
|
|
59e51c348a | ||
|
|
a056a9abfb | ||
|
|
33ae780103 | ||
|
|
daf56514f7 | ||
|
|
8bcbceb971 | ||
|
|
df01f36442 | ||
|
|
b0024aa669 | ||
|
|
4f1ee37508 | ||
|
|
fec0347cd6 | ||
|
|
c99e228669 | ||
|
|
95d495f290 |
@@ -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: 52.8%"><title>coverage: 52.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">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -16,6 +16,7 @@ dws
|
||||
test/cli/testdata/
|
||||
tmp/
|
||||
test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
|
||||
@@ -28,3 +29,5 @@ credentials*
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
*.code-workspace
|
||||
/dingtalk-workspace.zip
|
||||
|
||||
+358
@@ -4,6 +4,364 @@ 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.16] - 2026-04-24
|
||||
|
||||
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
|
||||
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
|
||||
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
|
||||
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
|
||||
|
||||
## [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.14] - 2026-04-22
|
||||
|
||||
Docs-only re-tag of v1.0.13. The single commit (#153) backfills the v1.0.13 release notes after the binary was already published; no functional or CLI surface change.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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,24 +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` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
|
||||
| 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 |
|
||||
|
||||
> 86 commands across 12 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>
|
||||
|
||||
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `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>
|
||||
|
||||
@@ -416,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
@@ -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) — 版本历史与迁移说明
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,9 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Cleanup keychain after test
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||
)
|
||||
|
||||
// authRetryingKey marks a context that has already attempted one
|
||||
// AuthRefreshRequired-driven retry of the current invocation. The runner uses
|
||||
// this to refuse a second refresh+retry pass and surface the original cause
|
||||
// to the user instead.
|
||||
type authRetryingKeyType struct{}
|
||||
|
||||
var authRetryingKey = authRetryingKeyType{}
|
||||
|
||||
// IsAuthRetrying reports whether the current context is already inside an
|
||||
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
||||
func IsAuthRetrying(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// withAuthRetrying returns a child context flagged as "already retried once"
|
||||
// so the runner does not enter an infinite refresh loop if the second attempt
|
||||
// also returns AuthRefreshRequired.
|
||||
func withAuthRetrying(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, authRetryingKey, true)
|
||||
}
|
||||
|
||||
// handleAuthRefreshRequired performs a one-shot ForceRefresh using the active
|
||||
// configDir and re-runs the invocation through the supplied runner. It must
|
||||
// only be called when the runner has observed an *authretry.AuthRefreshRequired
|
||||
// from an edition hook (ClassifyToolResult / OnAuthError).
|
||||
//
|
||||
// Behaviour rules — all three matter for safety:
|
||||
// 1. If the context is already flagged via IsAuthRetrying, this returns
|
||||
// refresh.Cause unchanged. No further refresh attempts, no recursion.
|
||||
// 2. If ForceRefresh fails (e.g. refresh_token also expired), this returns
|
||||
// refresh.Cause so the user sees the original auth diagnostic, not an
|
||||
// internal "force refresh failed" message.
|
||||
// 3. On successful refresh, this resets the per-process token cache and
|
||||
// re-runs the invocation with withAuthRetrying applied so a second
|
||||
// refresh request from the overlay degrades gracefully to "show the
|
||||
// original error".
|
||||
func handleAuthRefreshRequired(
|
||||
ctx context.Context,
|
||||
r executor.Runner,
|
||||
invocation executor.Invocation,
|
||||
refresh *authretry.AuthRefreshRequired,
|
||||
) (executor.Result, error) {
|
||||
if refresh == nil {
|
||||
return executor.Result{}, nil
|
||||
}
|
||||
if IsAuthRetrying(ctx) {
|
||||
return executor.Result{}, refresh.Cause
|
||||
}
|
||||
if _, err := ForceRefreshAccessToken(ctx, defaultConfigDir()); err != nil {
|
||||
return executor.Result{}, refresh.Cause
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
return r.Run(withAuthRetrying(ctx), invocation)
|
||||
}
|
||||
@@ -80,11 +80,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,11 +216,17 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// Regression for the chat/bot tool routing bug: when the `chat` envelope
|
||||
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
|
||||
// `send_message_by_custom_robot`), those tool names must NOT be registered
|
||||
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
|
||||
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
|
||||
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
|
||||
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
|
||||
// tool.
|
||||
//
|
||||
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
|
||||
// side), so product-level and tool-level lookups both resolve correctly.
|
||||
|
||||
const (
|
||||
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
|
||||
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
|
||||
)
|
||||
|
||||
// botDescriptor returns a minimal `bot` server descriptor that owns the
|
||||
// `search_my_robots` + `send_message_by_custom_robot` tools (no
|
||||
// serverOverride — bot is the real owner).
|
||||
func botDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testBotEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "bot",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_my_robots": {CLIName: "search"},
|
||||
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
|
||||
"add_robot_to_group": {CLIName: "add-bot"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// chatDescriptor returns a minimal `chat` server descriptor whose
|
||||
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
|
||||
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
|
||||
// chat's endpoint.
|
||||
func chatDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testChatEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "chat",
|
||||
Command: "chat",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_groups_by_keyword": {CLIName: "search"},
|
||||
"search_my_robots": {
|
||||
CLIName: "search",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"send_message_by_custom_robot": {
|
||||
CLIName: "send-by-webhook",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"add_robot_to_group": {
|
||||
CLIName: "add-bot",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
|
||||
// registries so parallel/other tests aren't affected by this case's mutations.
|
||||
func withCleanDynamicRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func assertEndpoint(t *testing.T, productID, toolName, want string) {
|
||||
t.Helper()
|
||||
got, ok := directRuntimeEndpoint(productID, toolName)
|
||||
if !ok {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
|
||||
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
|
||||
// of registration order.
|
||||
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "bot first, chat second",
|
||||
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "chat first, bot second",
|
||||
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Bot-owned tools must route to bot's endpoint even though chat
|
||||
// declares toolOverrides for them (with serverOverride="bot").
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
|
||||
|
||||
// Chat-native tools must still route to chat.
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
|
||||
// Product-level fallback for bot (no tool name) must also return
|
||||
// bot's endpoint.
|
||||
assertEndpoint(t, "bot", "", testBotEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
|
||||
// the plugin-injection path (`AppendDynamicServer`) which has the same
|
||||
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
|
||||
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
|
||||
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
orders := [][]market.ServerDescriptor{
|
||||
{botDescriptor(), chatDescriptor()},
|
||||
{chatDescriptor(), botDescriptor()},
|
||||
}
|
||||
|
||||
for _, servers := range orders {
|
||||
t.Run("", func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
for _, s := range servers {
|
||||
AppendDynamicServer(s)
|
||||
}
|
||||
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -35,12 +36,12 @@ type GlobalFlags struct {
|
||||
}
|
||||
|
||||
func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", "Override OAuth client ID (DingTalk AppKey)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", i18n.T("覆盖 OAuth 客户端 ID (钉钉 AppKey)"))
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", i18n.T("覆盖 OAuth 客户端密钥 (钉钉 AppSecret)"))
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
||||
// the new access_token. It is intended for callers that have observed a
|
||||
// server-side rejection (HTTP 401 or business code such as
|
||||
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
||||
//
|
||||
// Steps:
|
||||
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
|
||||
// OAuthProvider.GetAccessToken's fast-path will miss.
|
||||
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
|
||||
// existing dual-layer lock (process + file) to serialize concurrent
|
||||
// refresh attempts across goroutines and processes.
|
||||
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
|
||||
// next resolveAuthToken call re-reads from disk.
|
||||
//
|
||||
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
|
||||
// is the only entry point that orchestrates "force refresh" semantics.
|
||||
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
|
||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||
}
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tok, err := provider.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("force refresh returned empty access token")
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
return tok, nil
|
||||
}
|
||||
+215
-22
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -47,12 +48,46 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
var commands []*cobra.Command
|
||||
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
|
||||
commands = append(commands, dynamicCmds...)
|
||||
dynamicCmds := loadDynamicCommands(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
// same-named top-level products, helper-only leaves are grafted into the
|
||||
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
|
||||
// remains the authority for leaves it declares, while hardcoded helpers can
|
||||
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
|
||||
// alongside the envelope's `chat message send`).
|
||||
//
|
||||
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
|
||||
// on same-named top-level commands, which — at leaf conflicts — falls back to
|
||||
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
|
||||
// typically expose more flags than the corresponding dynamic overlay leaves,
|
||||
// so a naive append would silently promote helper leaves over their dynamic
|
||||
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
|
||||
// leaf conflict, and only adding subtrees the dynamic side lacks.
|
||||
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
|
||||
dynByName := make(map[string]*cobra.Command, len(dynamic))
|
||||
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
|
||||
for _, c := range dynamic {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
dynByName[c.Name()] = c
|
||||
out = append(out, c)
|
||||
}
|
||||
commands = append(commands, helpers.NewPublicCommands(runner)...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
for _, h := range helpers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if dyn := dynByName[h.Name()]; dyn != nil {
|
||||
cmdutil.MergeHardcodedLeaves(dyn, h)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectStaticServers converts edition.ServerInfo entries into
|
||||
@@ -87,9 +122,58 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
//
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
// editionPartition returns the cache partition for the active edition.
|
||||
// Each edition gets its own partition to prevent cross-edition data leakage.
|
||||
func editionPartition() string {
|
||||
name := edition.Get().Name
|
||||
if name == "" || name == "open" {
|
||||
return config.DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
}
|
||||
|
||||
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
|
||||
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
|
||||
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
|
||||
func discoveryTraceEnabled() bool {
|
||||
if IsPerfDebugEnabled() {
|
||||
return true
|
||||
}
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--debug" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range servers {
|
||||
id := strings.TrimSpace(s.CLI.Command)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(s.CLI.ID)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
const maxIDs = 48
|
||||
if len(out) > maxIDs {
|
||||
out = out[:maxIDs]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
store := cacheStoreFromEnv()
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
|
||||
// Bypass the registry cache when a fixture override is active.
|
||||
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
|
||||
@@ -116,27 +200,55 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) > 0 && discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
|
||||
"partition", partition,
|
||||
"servers", len(servers),
|
||||
"registry_freshness", string(freshness))
|
||||
}
|
||||
|
||||
// Cache miss or bypassed: fetch from market API synchronously (first run only).
|
||||
if len(servers) == 0 {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
if discoveryTraceEnabled() {
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
}
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
|
||||
}
|
||||
}
|
||||
fetchStart := time.Now()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
|
||||
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
|
||||
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
|
||||
if fetchErr != nil {
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch failed",
|
||||
"partition", partition,
|
||||
"error", fetchErr.Error())
|
||||
}
|
||||
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
|
||||
// Degrade to stale cache if available (production only).
|
||||
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
|
||||
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
|
||||
servers = snapshot.Servers
|
||||
} else {
|
||||
return nil
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
"response_servers", len(resp.Servers),
|
||||
"metadata_count", resp.Metadata.Count,
|
||||
"normalized_servers", len(servers),
|
||||
"cli_command_ids", discoveryTraceServerIDs(servers))
|
||||
}
|
||||
// Persist fresh data (only in non-test mode).
|
||||
if useCache {
|
||||
saveStart := time.Now()
|
||||
@@ -148,9 +260,22 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
}
|
||||
|
||||
// FallbackServers: safety net when Market discovery + cache both fail.
|
||||
if len(servers) == 0 {
|
||||
if fn := edition.Get().FallbackServers; fn != nil {
|
||||
if fb := fn(); len(fb) > 0 {
|
||||
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
|
||||
descriptors := fallbackToDescriptors(fb)
|
||||
descriptors = mergeSupplementServers(descriptors)
|
||||
SetDynamicServers(descriptors)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge edition-specific supplement servers (not in Market).
|
||||
servers = mergeSupplementServers(servers)
|
||||
// Inject dynamic server data for endpoint resolution
|
||||
SetDynamicServers(servers)
|
||||
|
||||
@@ -172,7 +297,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
if server.DetailLocator.MCPID <= 0 {
|
||||
continue
|
||||
@@ -203,7 +328,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
now := time.Now().UTC()
|
||||
if store != nil && store.Now != nil {
|
||||
now = store.Now().UTC()
|
||||
@@ -333,13 +458,13 @@ func DiscoveryBaseURL() string {
|
||||
return cli.DefaultMarketBaseURL
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections
|
||||
// and uses a short timeout suitable for CLI startup network requests.
|
||||
// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
|
||||
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
|
||||
// hosts without IPv6 networking.
|
||||
func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||
return &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
@@ -348,6 +473,29 @@ func ipv4OnlyHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
|
||||
// suitable for CLI startup network requests.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
return ipv4HTTPClient(5 * time.Second)
|
||||
}
|
||||
|
||||
// fetchRegistryServers performs the server-list HTTP fetch honoring the
|
||||
// active edition's DiscoveryURL override. It is the single source of truth
|
||||
// for all server-list fetches (startup, async revalidation, explicit
|
||||
// `cache refresh`); keeping the edition-URL branch in one place prevents
|
||||
// call sites from drifting out of sync.
|
||||
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
client := market.NewClient("", httpClient)
|
||||
if fn := edition.Get().DiscoveryHeaders; fn != nil {
|
||||
client.Headers = fn()
|
||||
}
|
||||
return client.FetchServersFromURL(ctx, editionURL)
|
||||
}
|
||||
client := market.NewClient(DiscoveryBaseURL(), httpClient)
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
@@ -355,9 +503,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
baseURL := DiscoveryBaseURL()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
if err != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
@@ -398,3 +544,50 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSupplementServers appends edition-specific servers (not in Market)
|
||||
// into the discovery result. Existing IDs from Market/cache take precedence.
|
||||
func mergeSupplementServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
|
||||
fn := edition.Get().SupplementServers
|
||||
if fn == nil {
|
||||
return servers
|
||||
}
|
||||
existing := make(map[string]bool, len(servers))
|
||||
for _, s := range servers {
|
||||
existing[s.CLI.ID] = true
|
||||
existing[s.Key] = true
|
||||
}
|
||||
for _, sup := range fn() {
|
||||
if !existing[sup.ID] {
|
||||
servers = append(servers, market.ServerDescriptor{
|
||||
Key: sup.ID,
|
||||
DisplayName: sup.Name,
|
||||
Endpoint: sup.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: sup.ID,
|
||||
Command: sup.ID,
|
||||
Prefixes: sup.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// fallbackToDescriptors converts edition.ServerInfo into market.ServerDescriptor.
|
||||
func fallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
|
||||
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
|
||||
// the dynamic one wins — envelopes remain the runtime authority for behaviour
|
||||
// they declare. The helper subtree must not slip in via
|
||||
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
|
||||
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
|
||||
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
|
||||
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
|
||||
dyn.AddCommand(dynTask)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
|
||||
hlp := &cobra.Command{Use: "todo", Short: "helper"}
|
||||
hlp.AddCommand(hlpTask)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
// The dynamic leaf must still be the one we find under the top-level name.
|
||||
var found *cobra.Command
|
||||
for _, c := range got[0].Commands() {
|
||||
if c.Name() == "task" {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found != dynTask {
|
||||
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
|
||||
// registers siblings the discovery envelope did NOT declare (e.g.
|
||||
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
|
||||
// envelope's `chat message send`), those helper-only leaves are grafted into
|
||||
// the dynamic subtree instead of being dropped. This is a regression guard:
|
||||
// prior to this fix, pickCommands silently dropped the entire helper subtree
|
||||
// whenever the top-level product name collided, which disappeared every
|
||||
// helper-only leaf the envelope didn't cover.
|
||||
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
|
||||
dynMessage := &cobra.Command{Use: "message"}
|
||||
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynMessage)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMessage := &cobra.Command{Use: "message"}
|
||||
hlpMessage.AddCommand(helperOnlyLeaf)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpMessage)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
var grafted *cobra.Command
|
||||
for _, child := range dynMessage.Commands() {
|
||||
if child.Name() == "send-by-bot" {
|
||||
grafted = child
|
||||
}
|
||||
}
|
||||
if grafted == nil {
|
||||
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
|
||||
}
|
||||
if grafted != helperOnlyLeaf {
|
||||
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
|
||||
// names are NOT in the dynamic set are preserved — the dynamic overlay only
|
||||
// shadows products it actually covers.
|
||||
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
attendanceHelper := &cobra.Command{Use: "attendance"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
names := make(map[string]*cobra.Command, len(got))
|
||||
for _, c := range got {
|
||||
names[c.Name()] = c
|
||||
}
|
||||
if names["todo"] != dyn {
|
||||
t.Fatalf("todo = %v, want dynamic", names["todo"])
|
||||
}
|
||||
if names["attendance"] != attendanceHelper {
|
||||
t.Fatalf("attendance not preserved from helpers")
|
||||
}
|
||||
if names["chat"] != chatHelper {
|
||||
t.Fatalf("chat not preserved from helpers")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
|
||||
// when discovery returns nothing, helpers are the sole source of truth — the
|
||||
// behaviour must be identical to the pre-refactor append-all code path.
|
||||
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, chatHelper}
|
||||
|
||||
got := pickCommands(nil, helpers)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2", len(got))
|
||||
}
|
||||
if got[0] != todoHelper || got[1] != chatHelper {
|
||||
t.Fatalf("pickCommands changed helpers order or identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
|
||||
// a misbehaving factory.
|
||||
func TestPickCommands_NilsAreSkipped(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
|
||||
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
|
||||
}
|
||||
if got[0] != dyn || got[1] != hlp {
|
||||
t.Fatalf("unexpected ordering or identity after nil filter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// PatAuthRetryTimeout is the maximum time to wait for user authorization
|
||||
// when a PAT scope error is detected.
|
||||
PatAuthRetryTimeout = 10 * time.Minute
|
||||
|
||||
// PatAuthPollInterval is how often we poll to check if the user has
|
||||
// completed authorization.
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
Identity string
|
||||
ErrorType string
|
||||
Message string
|
||||
Hint string
|
||||
MissingScope string
|
||||
}
|
||||
|
||||
func (e *PatScopeError) Error() string {
|
||||
return e.OriginalError
|
||||
}
|
||||
|
||||
// patScopeRegex matches PAT-protocol scope error patterns from the API.
|
||||
// Only matches explicit scope-related keywords; generic "permission denied" or
|
||||
// "forbidden" are intentionally excluded to avoid false positives on business
|
||||
// authorization errors (e.g. mailbox access denied, 403 Forbidden).
|
||||
var patScopeRegex = regexp.MustCompile(`(?i)(missing_scope|insufficient_scope|scope.*required)`)
|
||||
|
||||
// scopeValueRegex extracts a scope identifier (e.g. "calendar:read",
|
||||
// "mail:user_mailbox.message:send") from an error message.
|
||||
// Supports multi-segment scopes with multiple colons (resource:sub:action).
|
||||
var scopeValueRegex = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)`)
|
||||
|
||||
// identityValueRegex extracts an identity label from an error message.
|
||||
var identityValueRegex = regexp.MustCompile(`(?i)identity["\s:]+([a-zA-Z_]+)`)
|
||||
|
||||
// isPatScopeError checks if an error looks like a PAT scope/permission error
|
||||
// that can be resolved by re-authorizing with additional scopes.
|
||||
func isPatScopeError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
|
||||
// Check for missing_scope pattern in error message or hint
|
||||
if patScopeRegex.MatchString(msg) {
|
||||
return true
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
// Check message, reason, and hint for scope-related patterns
|
||||
fullText := strings.ToLower(typed.Message + " " + typed.Reason + " " + typed.Hint)
|
||||
if typed.Category == apperrors.CategoryAuth {
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") ||
|
||||
(strings.Contains(fullText, "scope") && strings.Contains(fullText, "required")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Any category with scope/permission hints
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPatScopeError parses an error to extract PAT scope details.
|
||||
func extractPatScopeError(err error) *PatScopeError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
scope := ""
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
msg = typed.Message
|
||||
if typed.Reason != "" {
|
||||
msg += " (" + typed.Reason + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract scope value (e.g. "calendar:read") from error message.
|
||||
scopeMatch := scopeValueRegex.FindStringSubmatch(msg)
|
||||
if len(scopeMatch) > 1 {
|
||||
scope = scopeMatch[1]
|
||||
}
|
||||
|
||||
// Try to extract identity from error message.
|
||||
identity := "user"
|
||||
identityMatch := identityValueRegex.FindStringSubmatch(msg)
|
||||
if len(identityMatch) > 1 {
|
||||
identity = identityMatch[1]
|
||||
}
|
||||
|
||||
return &PatScopeError{
|
||||
OriginalError: err.Error(),
|
||||
Identity: identity,
|
||||
ErrorType: "missing_scope",
|
||||
Message: msg,
|
||||
Hint: fmt.Sprintf("run `dws auth login --scope %q` to authorize the missing scope", scope),
|
||||
MissingScope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// PrintPatAuthError prints a human-readable PAT authorization error.
|
||||
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "{\n")
|
||||
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"identity": scopeErr.Identity,
|
||||
"error": map[string]any{
|
||||
"type": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
},
|
||||
}
|
||||
if scopeErr.MissingScope != "" {
|
||||
payload["missing_scope"] = scopeErr.MissingScope
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
timeout := PatAuthRetryTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
defer pollTicker.Stop()
|
||||
start := time.Now()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
pollCount++
|
||||
elapsed := time.Since(start).Truncate(time.Second)
|
||||
remaining := time.Until(deadline).Truncate(time.Second)
|
||||
|
||||
// Check if token is now valid
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
|
||||
// It waits for the user to complete authorization and then retries the invocation.
|
||||
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
|
||||
// Print the PAT error in human-readable format
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
// Wait for user to complete authorization
|
||||
authorized := WaitForPatAuthorization(ctx, configDir, output)
|
||||
if !authorized {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"等待用户授权超时",
|
||||
apperrors.WithReason("pat_auth_timeout"),
|
||||
apperrors.WithHint(fmt.Sprintf("授权超时 (%s),请重新执行命令", PatAuthRetryTimeout)),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the token cache so the new token is loaded
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
}
|
||||
|
||||
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
|
||||
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// patRetryingKey is a context key to prevent recursive PAT auth checks.
|
||||
// After APPROVED, the retry should not trigger another PAT flow.
|
||||
type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
// polls the device flow endpoint until the user authorizes, and retries the
|
||||
// original invocation on success.
|
||||
func handlePatAuthCheck(
|
||||
ctx context.Context,
|
||||
r *runtimeRunner,
|
||||
invocation executor.Invocation,
|
||||
patErr *apperrors.PATError,
|
||||
configDir string,
|
||||
output io.Writer,
|
||||
) (executor.Result, error) {
|
||||
// Parse authorization details from PATError.RawJSON.
|
||||
var patData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
"flowId", patData.Data.FlowID,
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
|
||||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||||
if patData.Data.ClientID != "" {
|
||||
if patData.Data.ClientSecret != "" {
|
||||
// When both clientId and clientSecret are provided, use direct mode
|
||||
// (DingTalk API) rather than MCP proxy — the MCP proxy does not hold
|
||||
// the secret for this particular app.
|
||||
authpkg.SetClientID(patData.Data.ClientID)
|
||||
authpkg.SetClientSecret(patData.Data.ClientSecret)
|
||||
} else {
|
||||
// No clientSecret — rely on MCP proxy to manage the secret server-side.
|
||||
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
|
||||
}
|
||||
|
||||
// Persist clientId (and optionally secret) to ~/.dws/app.json so that
|
||||
// future process invocations can load it at startup and populate
|
||||
// DWS_CLIENT_ID env before the first MCP request.
|
||||
appCfg := &authpkg.AppConfig{
|
||||
ClientID: patData.Data.ClientID,
|
||||
}
|
||||
if patData.Data.ClientSecret != "" {
|
||||
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
|
||||
}
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
greenFn := color.New(color.FgGreen).SprintFunc()
|
||||
yellowFn := color.New(color.FgYellow).SprintFunc()
|
||||
redFn := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
|
||||
if patData.Data.Desc != "" {
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
// Best-effort browser open.
|
||||
_ = tryOpenBrowser(patData.Data.URI)
|
||||
}
|
||||
|
||||
// If no flowId, we can't poll — fall back to returning PATError for host-app.
|
||||
if patData.Data.FlowID == "" {
|
||||
fmt.Fprintln(output)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||
if authCode != "" {
|
||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
||||
if exchErr != nil {
|
||||
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// Workaround: brief delay to let server-side authorization state propagate
|
||||
// before retrying. Without this the retry may use stale credentials.
|
||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
)
|
||||
retryCtx := context.WithValue(ctx, patRetryingKey, true)
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
apperrors.WithHint("授权链接已过期,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
apperrors.WithHint("用户取消了授权操作。"),
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
// Load user access token for the poll request header.
|
||||
var accessToken string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
|
||||
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
||||
noRedirectClient := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.Canceled {
|
||||
return authpkg.StatusCancelled, "", nil
|
||||
}
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||
continue
|
||||
}
|
||||
if accessToken != "" {
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
}
|
||||
resp, err := noRedirectClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: request failed", "error", err)
|
||||
continue // transient network error, keep polling
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// If we got a redirect (302/301), SSO gateway intercepted — skip JSON parse.
|
||||
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
|
||||
continue
|
||||
}
|
||||
|
||||
var pollResp authpkg.DevicePollResponse
|
||||
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
|
||||
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
|
||||
continue
|
||||
}
|
||||
|
||||
status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success)
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, pollResp.Data.AuthCode, nil
|
||||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, "", nil
|
||||
case authpkg.StatusPending:
|
||||
// keep polling
|
||||
default:
|
||||
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
|
||||
// so this branch handles truly unknown statuses.
|
||||
fmt.Fprintln(output)
|
||||
return status, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestIsPatScopeError_MissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
|
||||
if !isPatScopeError(err) {
|
||||
t.Fatal("expected missing_scope error to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_PlainString(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PatScopeError{
|
||||
OriginalError: "missing_scope: user lacks required scope",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "user lacks required scope",
|
||||
}
|
||||
if !isPatScopeError(err) {
|
||||
t.Fatal("expected plain string with missing_scope to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_NotScopeError(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewValidation("invalid parameter")
|
||||
if isPatScopeError(err) {
|
||||
t.Fatal("expected validation error NOT to be detected as scope error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
if isPatScopeError(nil) {
|
||||
t.Fatal("nil error should not be detected as scope error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_WithReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("API error",
|
||||
apperrors.WithReason("missing_scope"),
|
||||
)
|
||||
if !isPatScopeError(err) {
|
||||
t.Fatal("expected error with missing_scope reason to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_InsufficientScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("insufficient_scope for resource",
|
||||
apperrors.WithReason("insufficient_scope"),
|
||||
)
|
||||
if !isPatScopeError(err) {
|
||||
t.Fatal("expected insufficient_scope error to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_MissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.ErrorType != "missing_scope" {
|
||||
t.Errorf("expected error type 'missing_scope', got %q", scopeErr.ErrorType)
|
||||
}
|
||||
if !strings.Contains(scopeErr.Hint, "dws auth login") {
|
||||
t.Errorf("expected hint to contain 'dws auth login', got %q", scopeErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_ExtractsScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PatScopeError{
|
||||
OriginalError: "missing_scope: user needs calendar:read",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "user needs calendar:read",
|
||||
}
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.MissingScope != "calendar:read" {
|
||||
t.Errorf("expected MissingScope 'calendar:read', got %q", scopeErr.MissingScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPatAuthError_HumanReadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:user_mailbox.message:send",
|
||||
Hint: "run `dws auth login --scope \"mail:user_mailbox.message:send\"` to authorize",
|
||||
MissingScope: "mail:user_mailbox.message:send",
|
||||
}
|
||||
PrintPatAuthError(&buf, scopeErr)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "missing_scope") {
|
||||
t.Errorf("expected output to contain 'missing_scope', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "dws auth login") {
|
||||
t.Errorf("expected output to contain 'dws auth login', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "需要额外授权") {
|
||||
t.Errorf("expected output to contain Chinese auth prompt, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
ErrorType: "missing_scope",
|
||||
Message: "missing required scope(s): mail:send",
|
||||
Hint: "run dws auth login --scope mail:send",
|
||||
MissingScope: "mail:send",
|
||||
}
|
||||
PrintPatAuthJSON(&buf, scopeErr)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"ok": false`) {
|
||||
t.Errorf("expected JSON to contain ok: false, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, `"missing_scope": "mail:send"`) {
|
||||
t.Errorf("expected JSON to contain missing_scope, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_BusinessPermissionDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Generic business "permission denied" should NOT trigger PAT re-auth.
|
||||
err := apperrors.NewAuth("User has no permission to access this mailbox, permission denied")
|
||||
if isPatScopeError(err) {
|
||||
t.Fatal("generic 'permission denied' should not be detected as PAT scope error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatScopeError_GenericForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
// HTTP 403 Forbidden should NOT trigger PAT re-auth.
|
||||
err := apperrors.NewAuth("403 Forbidden")
|
||||
if isPatScopeError(err) {
|
||||
t.Fatal("'403 Forbidden' should not be detected as PAT scope error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_ComplexScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.MissingScope != "mail:user_mailbox.message:send" {
|
||||
t.Errorf("expected MissingScope 'mail:user_mailbox.message:send', got %q", scopeErr.MissingScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatScopeError_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PatScopeError{
|
||||
OriginalError: "test error message",
|
||||
}
|
||||
if err.Error() != "test error message" {
|
||||
t.Errorf("expected Error() to return OriginalError, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pollPatDeviceFlow integration tests — httptest mock covering four terminal
|
||||
// states: APPROVED, REJECTED, EXPIRED, CANCELLED (ctx cancel).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// setupPollServer creates an httptest server that responds to
|
||||
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
|
||||
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
|
||||
// GetMCPBaseURL() returns the test server address.
|
||||
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
var callCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idx := int(callCount.Add(1)) - 1
|
||||
if idx >= len(statuses) {
|
||||
idx = len(statuses) - 1
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(statuses[idx])
|
||||
}))
|
||||
|
||||
// Write mcp_url so GetMCPBaseURL picks up the test server.
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
return server, tmpDir
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_Approved(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}},
|
||||
{Success: true, Data: authpkg.DevicePollData{Status: "APPROVED", AuthCode: "code123"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-1", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "APPROVED" {
|
||||
t.Errorf("expected APPROVED, got %q", status)
|
||||
}
|
||||
if authCode != "code123" {
|
||||
t.Errorf("expected authCode 'code123', got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_Rejected(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: false, Data: authpkg.DevicePollData{Status: "REJECTED"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-2", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "REJECTED" {
|
||||
t.Errorf("expected REJECTED, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Errorf("expected empty authCode for REJECTED, got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_Expired(t *testing.T) {
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: false, Data: authpkg.DevicePollData{Status: "EXPIRED"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-3", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "EXPIRED" {
|
||||
t.Errorf("expected EXPIRED, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Errorf("expected empty authCode for EXPIRED, got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_Cancelled(t *testing.T) {
|
||||
// Server always returns PENDING so context cancellation is the only exit.
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel immediately after first poll tick.
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-4", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "CANCELLED" {
|
||||
t.Errorf("expected CANCELLED, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Errorf("expected empty authCode for CANCELLED, got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IsPatRetrying tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsPatRetrying_Default(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
if IsPatRetrying(ctx) {
|
||||
t.Fatal("expected false for plain context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatRetrying_WithValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.WithValue(context.Background(), patRetryingKey, true)
|
||||
if !IsPatRetrying(ctx) {
|
||||
t.Fatal("expected true when pat retry key is set")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pollPatDeviceFlow edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
|
||||
// When server returns success=false with empty status, should treat as EXPIRED.
|
||||
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
|
||||
{Success: false, Data: authpkg.DevicePollData{Status: ""}},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, authCode, err := pollPatDeviceFlow(ctx, "flow-err", configDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "EXPIRED" {
|
||||
t.Errorf("expected EXPIRED for server error fallback, got %q", status)
|
||||
}
|
||||
if authCode != "" {
|
||||
t.Errorf("expected empty authCode for server error, got %q", authCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
|
||||
// When server returns 302 (SSO redirect), poll should continue until real response.
|
||||
var callCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount <= 1 {
|
||||
// First call: simulate SSO redirect
|
||||
w.Header().Set("Location", "https://sso.example.com")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Second call: return APPROVED
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := authpkg.DevicePollResponse{
|
||||
Success: true,
|
||||
Data: authpkg.DevicePollData{Status: "APPROVED"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
status, _, err := pollPatDeviceFlow(ctx, "flow-redirect", tmpDir, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != "APPROVED" {
|
||||
t.Errorf("expected APPROVED after redirect, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractPatScopeError edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractPatScopeError_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractPatScopeError(nil); got != nil {
|
||||
t.Fatalf("expected nil for nil error, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPatScopeError_WithIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := apperrors.NewAuth(`insufficient_scope: identity "app_user" needs calendar:write`)
|
||||
scopeErr := extractPatScopeError(err)
|
||||
if scopeErr == nil {
|
||||
t.Fatal("expected non-nil PatScopeError")
|
||||
}
|
||||
if scopeErr.Identity != "app_user" {
|
||||
t.Errorf("expected Identity 'app_user', got %q", scopeErr.Identity)
|
||||
}
|
||||
if scopeErr.MissingScope != "calendar:write" {
|
||||
t.Errorf("expected MissingScope 'calendar:write', got %q", scopeErr.MissingScope)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handlePatAuthCheck integration tests — cover the main orchestrator with
|
||||
// mock runner + httptest poll server for APPROVED, REJECTED, EmptyFlowID.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mockRunner is a simple executor.Runner for testing handlePatAuthCheck.
|
||||
type mockRunner struct {
|
||||
runFunc func(ctx context.Context, inv executor.Invocation) (executor.Result, error)
|
||||
}
|
||||
|
||||
func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
return m.runFunc(ctx, inv)
|
||||
}
|
||||
|
||||
// setupHandlePATServer creates an httptest server for handlePatAuthCheck tests.
|
||||
// It responds to device poll requests with the given status after the first poll.
|
||||
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
var pollCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/cli/oauth/device/poll") {
|
||||
idx := int(pollCount.Add(1)) - 1
|
||||
var resp authpkg.DevicePollResponse
|
||||
if idx == 0 {
|
||||
resp = authpkg.DevicePollResponse{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}}
|
||||
} else {
|
||||
resp = authpkg.DevicePollResponse{
|
||||
Success: terminalStatus == "APPROVED",
|
||||
Data: authpkg.DevicePollData{Status: terminalStatus, AuthCode: authCode},
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
return server, tmpDir
|
||||
}
|
||||
|
||||
func makePATErrorJSON(flowID, clientID string) string {
|
||||
type patData struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
payload := struct {
|
||||
Code string `json:"code"`
|
||||
Data patData `json:"data"`
|
||||
}{
|
||||
Code: "AGENT_CODE_NOT_EXISTS",
|
||||
Data: patData{
|
||||
Desc: "test auth",
|
||||
FlowID: flowID,
|
||||
URI: "", // empty to avoid opening browser in test
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
var retryCalled bool
|
||||
var retryHasKey bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
retryHasKey = IsPatRetrying(ctx)
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-approved", "test-client-id")}
|
||||
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected mock runner to be called for retry")
|
||||
}
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
if cid := authpkg.ClientID(); cid != "test-client-id" {
|
||||
t.Errorf("expected ClientID 'test-client-id', got %q", cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
defer server.Close()
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called on REJECTED")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-rejected", "test-client-id")}
|
||||
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, configDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for REJECTED")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "用户已拒绝授权") {
|
||||
t.Errorf("expected rejection error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
|
||||
// No poll server needed — empty flowId means no polling, return PATError directly.
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
t.Fatal("runner should not be called when flowId is empty")
|
||||
return executor.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")}
|
||||
|
||||
ctx := context.Background()
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, tmpDir, &buf)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError when flowId is empty")
|
||||
}
|
||||
// Should return the original PATError.
|
||||
if _, ok := err.(*apperrors.PATError); !ok {
|
||||
t.Errorf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
+19
-26
@@ -20,13 +20,14 @@ import (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newPluginCommand() *cobra.Command {
|
||||
pluginCmd := newPlaceholderParent("plugin", "Manage plugins")
|
||||
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
||||
|
||||
pluginCmd.AddCommand(
|
||||
newPluginListCommand(),
|
||||
@@ -48,7 +49,7 @@ func newPluginCommand() *cobra.Command {
|
||||
func newPluginListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed plugins",
|
||||
Short: i18n.T("列出已安装的插件"),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
@@ -82,7 +83,7 @@ func newPluginListCommand() *cobra.Command {
|
||||
func newPluginInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install a plugin",
|
||||
Short: i18n.T("安装插件"),
|
||||
Example: ` dws plugin install --dir ./conference
|
||||
dws plugin install --git https://github.com/DingTalk-Real-AI/conference.git`,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -122,7 +123,7 @@ func newPluginInstallCommand() *cobra.Command {
|
||||
func newPluginInfoCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "info <name>",
|
||||
Short: "Show plugin details",
|
||||
Short: i18n.T("查看插件详情"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -152,7 +153,7 @@ func newPluginInfoCommand() *cobra.Command {
|
||||
func newPluginEnableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "enable <name>",
|
||||
Short: "Enable a plugin",
|
||||
Short: i18n.T("启用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -169,7 +170,7 @@ func newPluginEnableCommand() *cobra.Command {
|
||||
func newPluginDisableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable <name>",
|
||||
Short: "Disable a plugin (managed plugins can be disabled but not removed)",
|
||||
Short: i18n.T("禁用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -186,7 +187,7 @@ func newPluginDisableCommand() *cobra.Command {
|
||||
func newPluginRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a user plugin (managed plugins cannot be removed)",
|
||||
Short: i18n.T("卸载已安装的插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -208,7 +209,7 @@ func newPluginRemoveCommand() *cobra.Command {
|
||||
func newPluginValidateCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate <dir>",
|
||||
Short: "Validate a plugin.json",
|
||||
Short: i18n.T("校验 plugin.json"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -229,22 +230,15 @@ func newPluginValidateCommand() *cobra.Command {
|
||||
func newPluginCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Scaffold a new plugin directory",
|
||||
Short: i18n.T("脚手架生成新插件目录"),
|
||||
Example: ` dws plugin create my-tool
|
||||
dws plugin create my-tool --type managed --description "My awesome tool"`,
|
||||
dws plugin create my-tool --description "My awesome tool"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
pluginType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if pluginType == "" {
|
||||
pluginType = "user"
|
||||
}
|
||||
if pluginType != "managed" && pluginType != "user" {
|
||||
return apperrors.NewValidation("type must be 'managed' or 'user'")
|
||||
}
|
||||
pluginType := "user"
|
||||
|
||||
// Validate name format
|
||||
m := &plugin.Manifest{Name: name, Version: "0.1.0", Type: pluginType}
|
||||
@@ -352,14 +346,13 @@ Use this skill when the user mentions:
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("description", "", "Plugin description")
|
||||
cmd.Flags().String("type", "user", "Plugin type: managed or user")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginDevCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dev <dir>",
|
||||
Short: "Register a local directory as a dev plugin",
|
||||
Short: i18n.T("将本地目录注册为开发态插件"),
|
||||
Long: `Registers a plugin from a local source directory for development.
|
||||
The plugin is loaded directly from the source directory on next CLI invocation,
|
||||
without copying files to ~/.dws/plugins/. Use 'dws plugin dev --off <name>'
|
||||
@@ -413,7 +406,7 @@ to unregister.`,
|
||||
}
|
||||
|
||||
func newPluginConfigCommand() *cobra.Command {
|
||||
configCmd := newPlaceholderParent("config", "Manage plugin configuration")
|
||||
configCmd := newPlaceholderParent("config", i18n.T("管理插件配置"))
|
||||
configCmd.AddCommand(
|
||||
newPluginConfigSetCommand(),
|
||||
newPluginConfigGetCommand(),
|
||||
@@ -426,7 +419,7 @@ func newPluginConfigCommand() *cobra.Command {
|
||||
func newPluginConfigSetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set <plugin-name> <key> <value>",
|
||||
Short: "Set a plugin config value",
|
||||
Short: i18n.T("设置插件配置项"),
|
||||
Long: `Persistently set a configuration value for a plugin.
|
||||
The value is stored in ~/.dws/settings.json and automatically injected
|
||||
as an environment variable when the plugin is loaded.
|
||||
@@ -464,7 +457,7 @@ over values stored in settings.json.`,
|
||||
func newPluginConfigGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <plugin-name> <key>",
|
||||
Short: "Get a plugin config value",
|
||||
Short: i18n.T("读取插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -485,7 +478,7 @@ func newPluginConfigGetCommand() *cobra.Command {
|
||||
func newPluginConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <plugin-name>",
|
||||
Short: "List all config values for a plugin",
|
||||
Short: i18n.T("列出插件所有配置项"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -570,7 +563,7 @@ func newPluginConfigListCommand() *cobra.Command {
|
||||
func newPluginConfigUnsetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unset <plugin-name> <key>",
|
||||
Short: "Remove a plugin config value",
|
||||
Short: i18n.T("删除插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -614,7 +607,7 @@ func maskSensitiveValue(value string) string {
|
||||
func newPluginBuildCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "build <dir>",
|
||||
Short: "Build plugin's stdio server into a native binary",
|
||||
Short: i18n.T("将插件 stdio server 编译为原生二进制"),
|
||||
Long: `Runs the build command declared in plugin.json to compile the
|
||||
plugin's server into a single executable. This ensures plugin users
|
||||
don't need any language runtime (Node.js, Python, etc.) installed.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
|
||||
// instance is safe for goroutines saving tool snapshots concurrently, as long
|
||||
// as each goroutine targets a distinct (partition, serverKey). This mirrors
|
||||
// the real plugin discovery path where each goroutine owns one plugin/server.
|
||||
//
|
||||
// Each call serializes to its own "<key>.json.tmp" file followed by a
|
||||
// rename(2) to the final path, so concurrent writers targeting distinct keys
|
||||
// never collide. The invariant asserted here: after N parallel writes, the
|
||||
// Store returns each written snapshot intact under LoadTools.
|
||||
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
|
||||
const (
|
||||
partition = "default/default"
|
||||
writers = 16
|
||||
)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", idx)
|
||||
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
|
||||
ServerKey: key,
|
||||
}); err != nil {
|
||||
t.Errorf("SaveTools(%s): %v", key, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", i)
|
||||
snapshot, _, err := store.LoadTools(partition, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools(%s): %v", key, err)
|
||||
}
|
||||
if snapshot.ServerKey != key {
|
||||
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
|
||||
// write path by spraying distinct server descriptors in parallel. Afterwards
|
||||
// every injected product ID must be resolvable — a missing entry would
|
||||
// indicate a lost write through an un-synchronized map update.
|
||||
func TestAppendDynamicServerConcurrent(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("plugin-id-%d", idx)
|
||||
endpoint := fmt.Sprintf("https://example.test/%d", idx)
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("plugin-id-%d", i)
|
||||
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
|
||||
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
|
||||
// survives concurrent writers — every registered client must be looked up
|
||||
// afterwards. Uses nil client pointers since LookupStdioClient only compares
|
||||
// keys, not values.
|
||||
func TestRegisterStdioClientConcurrent(t *testing.T) {
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
key := fmt.Sprintf("plugin/%d", i)
|
||||
if _, ok := LookupStdioClient(key); !ok {
|
||||
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePluginColdTimeouts covers the three code paths of the env
|
||||
// parser: unset (defaults), valid duration (applied to all three slots),
|
||||
// and invalid duration (logged and ignored, defaults returned).
|
||||
func TestResolvePluginColdTimeouts(t *testing.T) {
|
||||
t.Run("defaults when env unset", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
|
||||
}
|
||||
if got.httpAuth != 1500*time.Millisecond {
|
||||
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
|
||||
}
|
||||
if got.stdio != 2*time.Second {
|
||||
t.Errorf("stdio = %v, want 2s", got.stdio)
|
||||
}
|
||||
})
|
||||
t.Run("env override applies to all slots", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
|
||||
got := resolvePluginColdTimeouts()
|
||||
want := 3500 * time.Millisecond
|
||||
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
|
||||
t.Errorf("override not propagated: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("invalid env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
|
||||
t.Errorf("invalid env should not override defaults: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "0")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
// from its manifest. It supports two forms:
|
||||
//
|
||||
// 1. inline JSON object in manifest.MCPServers[key].CLI
|
||||
// 2. a relative file path (JSON string) pointing to an external overlay
|
||||
// file anchored at the plugin root (e.g. "overlay.json")
|
||||
//
|
||||
// When no CLI metadata is present, a minimal overlay keyed by the server
|
||||
// name is returned so callers can still build an identity descriptor.
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
srv, ok := p.Manifest.MCPServers[sc.Key]
|
||||
if !ok || len(srv.CLI) == 0 {
|
||||
return overlay
|
||||
}
|
||||
|
||||
cliData := srv.CLI
|
||||
// A JSON string is interpreted as a relative path to an external
|
||||
// overlay file (e.g. "overlay.json") anchored at the plugin root.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
|
||||
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
|
||||
// Returns nil if tools is empty.
|
||||
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
detailTools := make([]market.DetailTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
return map[string][]market.DetailTool{overlayID: detailTools}
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json — no subprocess required.
|
||||
//
|
||||
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
|
||||
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
|
||||
// registration (legacy path).
|
||||
//
|
||||
// When a warm tools cache exists for this server, its DetailTools are passed
|
||||
// to BuildDynamicCommands so flag types are enriched from the last successful
|
||||
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
|
||||
// only; the next startup after a successful refresh picks up the full schema.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
store *cache.Store,
|
||||
) ([]*cobra.Command, market.ServerDescriptor, bool) {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, market.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
// Warm-cache enrichment: if a prior successful discovery wrote a
|
||||
// non-empty tool list, use its schema to enrich flag types.
|
||||
var detailsByID map[string][]market.DetailTool
|
||||
if store != nil {
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
|
||||
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides),
|
||||
"commands", len(cmds),
|
||||
"enriched", detailsByID != nil)
|
||||
|
||||
return cmds, descriptor, true
|
||||
}
|
||||
|
||||
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
|
||||
// subprocess and persists the result so the next startup can enrich
|
||||
// overlay-registered commands with typed flags. It never constructs cobra
|
||||
// commands; command registration has already happened synchronously from
|
||||
// the overlay before this function runs.
|
||||
//
|
||||
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
|
||||
// SaveTools entirely so a transient error cannot poison the warm cache
|
||||
// with a null-tools snapshot.
|
||||
func refreshStdioToolsCache(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
store *cache.Store,
|
||||
timeouts pluginColdTimeouts,
|
||||
) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return
|
||||
}
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
}); err != nil {
|
||||
slog.Warn("plugin: failed to persist stdio tools cache",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("plugin: stdio tools cache refreshed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
|
||||
}
|
||||
|
||||
// hasOverlayToolOverrides reports whether a stdio plugin server carries
|
||||
// enough CLI metadata to be registered via the overlay-first path. Used by
|
||||
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
|
||||
// buckets without doing the overlay parse twice.
|
||||
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
|
||||
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// withCleanStdioRegistry snapshots and restores the package-level stdio
|
||||
// client registry so tests that call RegisterStdioClient don't leak state
|
||||
// across cases.
|
||||
func withCleanStdioRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
|
||||
// CLIOverlay with the given tool-override map. The stdio client is created
|
||||
// but never started, since the overlay-first path does not require the
|
||||
// subprocess to be running for command registration.
|
||||
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal overlay: %v", err)
|
||||
}
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{
|
||||
Name: pluginName,
|
||||
Version: "1.0.0",
|
||||
Description: pluginName + " plugin",
|
||||
MCPServers: map[string]*plugin.MCPServer{
|
||||
serverKey: {
|
||||
Type: "stdio",
|
||||
Command: "/usr/bin/true", // never executed by overlay-first path
|
||||
CLI: raw,
|
||||
},
|
||||
},
|
||||
},
|
||||
Root: t.TempDir(),
|
||||
}
|
||||
sc := plugin.StdioServerClient{
|
||||
Key: serverKey,
|
||||
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
|
||||
}
|
||||
return p, sc
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
|
||||
// the core promise of the overlay-first path: when overlay.json ships
|
||||
// ToolOverrides, commands appear immediately — no subprocess probe.
|
||||
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
Groups: map[string]market.CLIGroupDef{
|
||||
"meeting": {Description: "会议控制"},
|
||||
"member": {Description: "成员管理"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
|
||||
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
|
||||
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
|
||||
}
|
||||
|
||||
var root *struct{ name, path string }
|
||||
_ = root
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "conference-local" {
|
||||
found = true
|
||||
// Groups must be attached as sub-commands.
|
||||
groups := map[string]bool{}
|
||||
for _, sub := range c.Commands() {
|
||||
groups[sub.Name()] = true
|
||||
}
|
||||
if !groups["meeting"] {
|
||||
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
|
||||
}
|
||||
if !groups["member"] {
|
||||
t.Errorf("missing 'member' group sub-command, children = %v", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
names := []string{}
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
|
||||
}
|
||||
|
||||
// AppendDynamicServer registration: product ID should land in
|
||||
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
|
||||
// visible even under a restrictive VisibleProducts hook.
|
||||
if !DirectRuntimeProductIDs()["conference-local"] {
|
||||
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
|
||||
}
|
||||
|
||||
// RegisterStdioClient side-effect: the runtime must be able to look up
|
||||
// the StdioClient when the endpoint is invoked later.
|
||||
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
|
||||
t.Error("LookupStdioClient missing conference-local/conference-local")
|
||||
}
|
||||
|
||||
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
|
||||
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
|
||||
// tools cache with a schema-bearing DetailTool and asserts the resulting
|
||||
// leaf command picks up the typed flag derived from InputSchema.
|
||||
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "cache-plugin",
|
||||
Command: "cache-plugin",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"echo": {CLIName: "echo", Description: "Echo input"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo the input",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"message": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools: %v", err)
|
||||
}
|
||||
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok || len(cmds) == 0 {
|
||||
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
|
||||
}
|
||||
|
||||
var echoLeaf *leafMatch
|
||||
for _, top := range cmds {
|
||||
if top.Name() != "cache-plugin" {
|
||||
continue
|
||||
}
|
||||
for _, sub := range top.Commands() {
|
||||
if sub.Name() == "echo" {
|
||||
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
if echoLeaf == nil {
|
||||
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
|
||||
}
|
||||
if !echoLeaf.hasFlag {
|
||||
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
|
||||
}
|
||||
}
|
||||
|
||||
type leafMatch struct {
|
||||
name string
|
||||
hasFlag bool
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
|
||||
// asserts the fallback contract: when overlay.json is missing toolOverrides,
|
||||
// the overlay-first path declines so the caller can route the entry through
|
||||
// the legacy discovery-first registerStdioServer.
|
||||
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
|
||||
// on runtime discovery for its tool list).
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "legacy-plugin",
|
||||
Command: "legacy-plugin",
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if ok {
|
||||
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
|
||||
}
|
||||
if cmds != nil {
|
||||
t.Errorf("cmds = %v, want nil", cmds)
|
||||
}
|
||||
if DirectRuntimeProductIDs()["legacy-plugin"] {
|
||||
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
|
||||
}
|
||||
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
|
||||
t.Error("stdio client must NOT be registered in fallback case")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
|
||||
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
|
||||
// timeout, empty tool list), the existing warm cache must remain intact so
|
||||
// the next startup still enriches flags from the last good snapshot.
|
||||
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
|
||||
ID: "refresh-plugin",
|
||||
Command: "refresh-plugin",
|
||||
})
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
goodSnapshot := cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "ping",
|
||||
Description: "Health check",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
|
||||
t.Fatalf("seed SaveTools: %v", err)
|
||||
}
|
||||
|
||||
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
|
||||
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
|
||||
// a no-op and must NOT overwrite the good cache with a null snapshot.
|
||||
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
|
||||
|
||||
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools after failed refresh: %v", err)
|
||||
}
|
||||
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
|
||||
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
|
||||
// test for the loadPlugins split decision: stdio plugins whose overlay ships
|
||||
// ToolOverrides must have their commands visible on the root immediately,
|
||||
// WITHOUT waiting on any discovery handshake. It drives the same sequence
|
||||
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
|
||||
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
|
||||
// visibility filter even when no discovery has run.
|
||||
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Simulate a wukong-like edition that declares a static VisibleProducts
|
||||
// whitelist NOT containing our plugin. This is the exact scenario where
|
||||
// the original bug surfaced.
|
||||
overrideVisibleProducts(t, []string{"calendar", "doc"})
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
// No discovery runs — no cache seeded. This mirrors a cold-start where
|
||||
// the subprocess is unavailable (or just slow) yet the user expects
|
||||
// `dws --help` to still list the plugin.
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false")
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// Also add a sibling command that is NOT a registered product so we can
|
||||
// prove the visibility filter still hides non-product commands.
|
||||
bogus := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(bogus)
|
||||
for _, c := range cmds {
|
||||
root.AddCommand(c)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
var pluginCmd *cobra.Command
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == "conference-local" {
|
||||
pluginCmd = c
|
||||
}
|
||||
}
|
||||
if pluginCmd == nil {
|
||||
t.Fatal("conference-local missing from root after overlay-first registration")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
|
||||
}
|
||||
if !bogus.Hidden {
|
||||
t.Error("bogus-not-a-product must be hidden by the visibility filter")
|
||||
}
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasOverlayToolOverrides exercises the split-decision helper used by
|
||||
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
|
||||
func TestHasOverlayToolOverrides(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
overlay market.CLIOverlay
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty overlay",
|
||||
overlay: market.CLIOverlay{ID: "x", Command: "x"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "overlay with overrides",
|
||||
overlay: market.CLIOverlay{
|
||||
ID: "x",
|
||||
Command: "x",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"foo": {CLIName: "foo"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
|
||||
got := hasOverlayToolOverrides(p, sc)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+250
-165
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
@@ -146,7 +147,7 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro
|
||||
return writeErr
|
||||
}
|
||||
if wantsJSONErrors(root) {
|
||||
return apperrors.PrintJSON(stdout, err)
|
||||
return apperrors.PrintJSON(stderr, err)
|
||||
}
|
||||
return apperrors.PrintHumanAt(stderr, err, resolveVerbosity(root))
|
||||
}
|
||||
@@ -315,6 +316,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
addPluginCommandsSafe(root, pluginCmds)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
caller := newToolCallerAdapter(runner, flags)
|
||||
fn(root, caller)
|
||||
@@ -415,20 +420,25 @@ func newCacheCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read cache refresh flags")
|
||||
}
|
||||
baseURL := DiscoveryBaseURL()
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
transportClient := transport.NewClient(nil)
|
||||
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
|
||||
// Market client here is only a fallback for Detail API calls inside
|
||||
// DiscoverAllRuntime; the primary server-list fetch below goes
|
||||
// through fetchRegistryServers so edition DiscoveryURL wins.
|
||||
service := discovery.NewService(
|
||||
market.NewClient(baseURL, nil),
|
||||
market.NewClient(DiscoveryBaseURL(), nil),
|
||||
transportClient,
|
||||
store,
|
||||
)
|
||||
servers, err := service.DiscoverServers(cmd.Context())
|
||||
|
||||
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
|
||||
if err != nil {
|
||||
return err
|
||||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "live_market")
|
||||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
selected := selectServersForProduct(servers, product)
|
||||
if strings.TrimSpace(product) != "" && len(selected) == 0 {
|
||||
@@ -645,16 +655,7 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
// Public utility commands (auth, cache, completion, version) are always kept
|
||||
// visible; explicitly hidden commands stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var allowedProducts map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowedProducts = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowedProducts[p] = true
|
||||
}
|
||||
} else {
|
||||
allowedProducts = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowedProducts := resolveVisibleProducts()
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"cache": true,
|
||||
@@ -767,6 +768,43 @@ func cacheStoreFromEnv() *cache.Store {
|
||||
return cache.NewStore(cacheDir)
|
||||
}
|
||||
|
||||
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
|
||||
// servers. Timeouts only apply to the *first* discovery for a given
|
||||
// plugin/server; subsequent startups take the warm cache path and bypass
|
||||
// the network entirely.
|
||||
type pluginColdTimeouts struct {
|
||||
httpNoAuth time.Duration
|
||||
httpAuth time.Duration
|
||||
stdio time.Duration
|
||||
}
|
||||
|
||||
// resolvePluginColdTimeouts returns the cold-discovery budget for plugin MCP
|
||||
// servers, applying the DWS_PLUGIN_COLD_TIMEOUT override when set. Defaults
|
||||
// are tuned so healthy cross-region HTTP endpoints succeed on a cold start
|
||||
// and Python/Node-based stdio plugins have headroom for interpreter load,
|
||||
// while an unreachable host still surrenders in bounded time.
|
||||
func resolvePluginColdTimeouts() pluginColdTimeouts {
|
||||
t := pluginColdTimeouts{
|
||||
httpNoAuth: 1 * time.Second,
|
||||
httpAuth: 1500 * time.Millisecond,
|
||||
stdio: 2 * time.Second,
|
||||
}
|
||||
raw := strings.TrimSpace(os.Getenv(cli.PluginColdTimeoutEnv))
|
||||
if raw == "" {
|
||||
return t
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
slog.Warn("plugin: ignoring invalid DWS_PLUGIN_COLD_TIMEOUT",
|
||||
"value", raw, "error", err)
|
||||
return t
|
||||
}
|
||||
t.httpNoAuth = d
|
||||
t.httpAuth = d
|
||||
t.stdio = d
|
||||
return t
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -1036,9 +1074,7 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
// precedence (InjectPluginConfigEnv skips already-set keys).
|
||||
pluginLoader.InjectPluginConfigEnv()
|
||||
|
||||
// 0a. Ensure default managed plugins are installed (first-run bootstrap).
|
||||
updater := plugin.NewUpdater(pluginLoader.PluginsDir, RawVersion())
|
||||
// Load TokenData once; reuse for plugin bootstrap, updates, and stdio injection.
|
||||
// Load TokenData once; reused for stdio injection below.
|
||||
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
|
||||
var userCtx *plugin.UserContext
|
||||
if tokenData != nil {
|
||||
@@ -1050,38 +1086,14 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
}
|
||||
}
|
||||
}
|
||||
accessToken := ""
|
||||
if tokenData != nil && tokenData.IsAccessTokenValid() {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
if accessToken != "" {
|
||||
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
installed := updater.EnsureManaged(bootstrapCtx, accessToken, os.Stderr)
|
||||
bootstrapCancel()
|
||||
if len(installed) > 0 {
|
||||
slog.Debug("plugin: bootstrapped managed plugins", "names", installed)
|
||||
}
|
||||
|
||||
// 0b. Check for managed plugin updates (non-blocking, best-effort).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
updated := updater.CheckAndUpdate(ctx, accessToken, os.Stderr)
|
||||
cancel()
|
||||
if len(updated) > 0 {
|
||||
slog.Debug("plugin: updated managed plugins", "names", updated)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Load official plugins (always enabled)
|
||||
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
|
||||
@@ -1110,33 +1122,15 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
}
|
||||
}
|
||||
|
||||
// Discover tools from HTTP servers in parallel when there are multiple
|
||||
// servers with auth headers (third-party services with higher latency).
|
||||
if len(httpServers) > 1 {
|
||||
type discoveryResult struct {
|
||||
commands []*cobra.Command
|
||||
}
|
||||
results := make([]discoveryResult, len(httpServers))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
go func(idx int, ps pluginServer) {
|
||||
defer wg.Done()
|
||||
results[idx].commands = registerHTTPServer(ps.plugin, ps.srv, tc, runner)
|
||||
}(i, ps)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, r := range results {
|
||||
pluginCmds = append(pluginCmds, r.commands...)
|
||||
}
|
||||
} else {
|
||||
for _, ps := range httpServers {
|
||||
cmds := registerHTTPServer(ps.plugin, ps.srv, tc, runner)
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
// Collect all stdio clients up front so HTTP + stdio discovery can run
|
||||
// concurrently — the slowest plugin (typically an unreachable HTTP
|
||||
// endpoint hitting its dial timeout) dominates the parallel wall-clock,
|
||||
// not the sum of every plugin's cold timeout.
|
||||
type stdioEntry struct {
|
||||
plugin *plugin.Plugin
|
||||
sc plugin.StdioServerClient
|
||||
}
|
||||
|
||||
// 4. Start stdio MCP servers, discover tools, and build CLI commands
|
||||
var stdioEntries []stdioEntry
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range p.StdioClients(userCtx) {
|
||||
// Use background context so the subprocess lives for the CLI
|
||||
@@ -1146,11 +1140,79 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
cmds := registerStdioServer(p, sc, runner)
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
stdioEntries = append(stdioEntries, stdioEntry{plugin: p, sc: sc})
|
||||
}
|
||||
}
|
||||
|
||||
// Share one cache.Store across all discovery goroutines. Each goroutine
|
||||
// writes to a distinct serverKey path ("tools/<plugin>_<server>.json") with
|
||||
// atomic tmp+rename, so concurrent writes to different keys never collide
|
||||
// on the filesystem. Global in-process registries (AppendDynamicServer,
|
||||
// RegisterStdioClient) carry their own sync.Mutex; see direct_runtime.go
|
||||
// and stdio_registry.go.
|
||||
sharedStore := cacheStoreFromEnv()
|
||||
coldTimeouts := resolvePluginColdTimeouts()
|
||||
|
||||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||||
// Plugins whose overlay.json declares ToolOverrides register their full
|
||||
// command tree up-front from manifest metadata alone — no subprocess
|
||||
// handshake required. This fixes the "discovery fails → no commands
|
||||
// ever appear" lock-out and keeps `dws --help` reliable even when the
|
||||
// underlying MCP server is temporarily unavailable.
|
||||
var legacyStdioEntries []stdioEntry
|
||||
for _, e := range stdioEntries {
|
||||
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
|
||||
if !ok {
|
||||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||||
continue
|
||||
}
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// Phase B: fan out discovery in parallel.
|
||||
// - HTTP plugins: same behaviour as before (discovery-first).
|
||||
// - stdio overlay-first plugins: async cache refresh only; their
|
||||
// commands are already registered. Failures are non-fatal and do
|
||||
// NOT poison the warm-cache with a null-tools snapshot.
|
||||
// - stdio legacy plugins (overlay without toolOverrides): preserve
|
||||
// the old discovery-first path for backwards compatibility.
|
||||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
go func(idx int, ps pluginServer) {
|
||||
defer wg.Done()
|
||||
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
|
||||
}(i, ps)
|
||||
}
|
||||
// overlay-first stdio: async refresh (no command building here).
|
||||
for _, e := range stdioEntries {
|
||||
if !hasOverlayToolOverrides(e.plugin, e.sc) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(e stdioEntry) {
|
||||
defer wg.Done()
|
||||
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
|
||||
}(e)
|
||||
}
|
||||
// legacy stdio: discovery-first (commands depend on tool list).
|
||||
for i, e := range legacyStdioEntries {
|
||||
wg.Add(1)
|
||||
go func(idx int, e stdioEntry) {
|
||||
defer wg.Done()
|
||||
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
|
||||
}(i, e)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, cmds := range httpResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
for _, cmds := range legacyStdioResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// 5. Register plugin hooks into pipeline engine
|
||||
if engine != nil {
|
||||
for _, p := range allPlugins {
|
||||
@@ -1174,7 +1236,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),
|
||||
)
|
||||
@@ -1183,25 +1244,69 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
return pluginCmds
|
||||
}
|
||||
|
||||
// pluginCacheKey derives the cache key used to persist a plugin MCP server's
|
||||
// tool list. Prefixed with "plugin:" so entries are namespaced apart from the
|
||||
// Market-derived cache, and visible distinctly via `dws cache status`.
|
||||
func pluginCacheKey(pluginName, serverKey string) string {
|
||||
return "plugin:" + pluginName + ":" + serverKey
|
||||
}
|
||||
|
||||
// registerHTTPServer discovers tools from a streamable-http MCP server and
|
||||
// builds CLI commands. Used for plugin-owned HTTP servers that provide CLI metadata.
|
||||
//
|
||||
// Startup-latency strategy (issue #119):
|
||||
// - Warm cache: build commands from the persisted tools snapshot
|
||||
// synchronously — no network I/O. `dws --help` returns in ms even when
|
||||
// the plugin endpoint is unreachable.
|
||||
// - Cold cache: synchronous discovery (Initialize + ListTools) with a tight
|
||||
// timeout. The outcome — success or failure — is persisted so the next
|
||||
// invocation hits the warm path. Refresh on demand via `dws cache clean`
|
||||
// / `dws cache refresh`; the cache TTL (7d) otherwise expires naturally.
|
||||
//
|
||||
// When the server descriptor carries AuthHeaders (from plugin.json "headers"),
|
||||
// a dedicated transport.Client is created with the plugin's Bearer token and
|
||||
// trusted domains so that third-party MCP servers requiring independent
|
||||
// authentication (e.g. Alibaba Cloud Bailian) can be discovered at startup.
|
||||
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner) []*cobra.Command {
|
||||
// Use a longer timeout for servers with custom auth headers (third-party
|
||||
// services may have higher latency than local/DingTalk endpoints).
|
||||
timeout := 2 * time.Second
|
||||
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
partition := config.DefaultPartition
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, srv.Key)
|
||||
|
||||
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
|
||||
slog.Debug("plugin: http server served from cache",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key,
|
||||
"tools", len(snapshot.Tools), "freshness", string(freshness))
|
||||
return buildHTTPCommandsFromTools(srv, snapshot.Tools, runner)
|
||||
}
|
||||
|
||||
// Cold cache: synchronous discovery. Persist the outcome even on failure
|
||||
// (empty tools == negative cache) so the next invocation takes the fast
|
||||
// path regardless of endpoint health.
|
||||
tools := discoverHTTPTools(p, srv, tc, timeouts)
|
||||
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
})
|
||||
return buildHTTPCommandsFromTools(srv, tools, runner)
|
||||
}
|
||||
|
||||
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
|
||||
// for an HTTP MCP server and returns the discovered tools. Returns nil on
|
||||
// any transport/protocol error; errors are logged at Debug level.
|
||||
func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
// Cold-path budget. An unreachable endpoint will burn the full window
|
||||
// via the TCP dial timeout; a healthy localhost/third-party endpoint
|
||||
// typically responds in <200 ms. Third-party servers with auth get a
|
||||
// slightly larger window to accommodate TLS + auth RTT. Operators with
|
||||
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
// The outcome is persisted as a negative cache so subsequent startups
|
||||
// (80 ms warm) are unaffected. See issue #119.
|
||||
timeout := timeouts.httpNoAuth
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
timeout = 10 * time.Second
|
||||
timeout = timeouts.httpAuth
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// If the plugin provides custom auth headers, create a dedicated client
|
||||
// so the Bearer token is sent to the third-party endpoint.
|
||||
discoveryClient := tc
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
discoveryClient = buildPluginAuthClient(tc, srv)
|
||||
@@ -1219,14 +1324,19 @@ func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *trans
|
||||
"plugin", p.Manifest.Name, "server", srv.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
if len(toolsResult.Tools) == 0 {
|
||||
// buildHTTPCommandsFromTools converts a tool list into Cobra commands via
|
||||
// the BuildDynamicCommands path. Returns nil for an empty tool list.
|
||||
func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
detailsByID := make(map[string][]market.DetailTool)
|
||||
var detailTools []market.DetailTool
|
||||
for _, tool := range toolsResult.Tools {
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
@@ -1246,23 +1356,17 @@ func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *trans
|
||||
// If the server has no ToolOverrides (e.g. third-party MCP servers that
|
||||
// only declare cli.id and cli.command), auto-generate one override per
|
||||
// discovered tool so BuildDynamicCommands can create leaf commands.
|
||||
if len(srv.CLI.ToolOverrides) == 0 && len(toolsResult.Tools) > 0 {
|
||||
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(toolsResult.Tools))
|
||||
for _, tool := range toolsResult.Tools {
|
||||
if len(srv.CLI.ToolOverrides) == 0 {
|
||||
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(tools))
|
||||
for _, tool := range tools {
|
||||
srv.CLI.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||||
CLIName: deriveToolCLIName(tool.Name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
return compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{srv}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: http server registered",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key,
|
||||
"tools", len(toolsResult.Tools), "commands", len(cmds))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
|
||||
@@ -1341,8 +1445,37 @@ func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
|
||||
// registerStdioServer initializes a stdio MCP server, discovers its tools
|
||||
// via ListTools, builds CLI commands, and registers the StdioClient for
|
||||
// runtime dispatch. Returns generated cobra commands.
|
||||
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner) []*cobra.Command {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
//
|
||||
// Warm-cache fast path (issue #119): when a tools snapshot is already cached
|
||||
// for this plugin/server, skip the Initialize + ListTools RPC round-trip and
|
||||
// rebuild commands directly from the snapshot. Cold cache falls back to
|
||||
// synchronous discovery with a 4s cap and persists the outcome.
|
||||
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
partition := config.DefaultPartition
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
|
||||
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
|
||||
slog.Debug("plugin: stdio server served from cache",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"tools", len(snapshot.Tools), "freshness", string(freshness))
|
||||
return buildStdioCommands(p, sc, snapshot.Tools, runner)
|
||||
}
|
||||
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
})
|
||||
return buildStdioCommands(p, sc, tools, runner)
|
||||
}
|
||||
|
||||
// discoverStdioTools performs the blocking Initialize + ListTools handshake
|
||||
// on a stdio MCP subprocess. Returns nil on any error (logged at Warn level).
|
||||
// The default 2s budget comfortably accommodates Python/Node runtimes whose
|
||||
// interpreter + dependency load dominates the first response. Operators with
|
||||
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
|
||||
defer cancel()
|
||||
|
||||
if _, err := sc.Client.Initialize(ctx); err != nil {
|
||||
@@ -1350,114 +1483,66 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
toolsResult, err := sc.Client.ListTools(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: stdio ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
if len(toolsResult.Tools) == 0 {
|
||||
// buildStdioCommands constructs Cobra commands from a tool list and
|
||||
// registers the runtime dispatch state (StdioClient + dynamic server).
|
||||
// Returns nil for an empty tool list.
|
||||
//
|
||||
// This is the legacy discovery-first path, used only for stdio plugins whose
|
||||
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
|
||||
// register commands up-front via registerStdioServerFromOverlay, bypassing
|
||||
// this function entirely (see plugin_stdio_overlay.go).
|
||||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio server has no tools",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build CLIOverlay: use manifest CLI metadata if present, else auto-generate.
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
if srv, ok := p.Manifest.MCPServers[sc.Key]; ok && len(srv.CLI) > 0 {
|
||||
cliData := srv.CLI
|
||||
// If cli is a JSON string, treat it as a relative file path to an overlay file.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
}
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided.
|
||||
// Auto-generate ToolOverrides from discovered tools when not provided
|
||||
// by the manifest/overlay (legacy discovery-first path).
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
|
||||
if len(overlay.Prefixes) == 0 {
|
||||
overlay.Prefixes = []string{serverID}
|
||||
overlay.Prefixes = []string{overlay.ID}
|
||||
}
|
||||
for _, tool := range toolsResult.Tools {
|
||||
for _, tool := range tools {
|
||||
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||||
IsSensitive: tool.Sensitive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
// Register with pluginName/serverKey format for cleanup by plugin name
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+serverID, sc.Client)
|
||||
|
||||
// Convert tool descriptors to DetailTool entries for flag generation.
|
||||
detailsByID := make(map[string][]market.DetailTool)
|
||||
var detailTools []market.DetailTool
|
||||
for _, tool := range toolsResult.Tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
detailsByID[serverID] = detailTools
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
detailsByID := toolsToDetails(tools, overlay.ID)
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: stdio server registered",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"tools", len(toolsResult.Tools), "commands", len(cmds))
|
||||
"tools", len(tools), "commands", len(cmds))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
|
||||
@@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
|
||||
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
|
||||
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
|
||||
// Kept deliberately generic (no edition-specific strings) — concrete values
|
||||
// belong to the overlay repo that installs the hooks, not to this open core.
|
||||
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var (
|
||||
editionHits atomic.Int32
|
||||
marketHits atomic.Int32
|
||||
gotHeaders atomic.Value // map[string]string
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/edition/apis":
|
||||
editionHits.Add(1)
|
||||
snapshot := map[string]string{
|
||||
"x-test-edition": r.Header.Get("x-test-edition"),
|
||||
"x-test-client": r.Header.Get("x-test-client"),
|
||||
}
|
||||
gotHeaders.Store(snapshot)
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 1},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Edition Service",
|
||||
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "testing",
|
||||
DiscoveryURL: srv.URL + "/cli/edition/apis",
|
||||
DiscoveryHeaders: func() map[string]string {
|
||||
return map[string]string{
|
||||
"x-test-edition": "custom",
|
||||
"x-test-client": "cli-refresh",
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmd := newCacheCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"refresh"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if got := editionHits.Load(); got != 1 {
|
||||
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
|
||||
}
|
||||
if got := marketHits.Load(); got != 0 {
|
||||
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
|
||||
}
|
||||
|
||||
headers, _ := gotHeaders.Load().(map[string]string)
|
||||
if headers == nil {
|
||||
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
|
||||
}
|
||||
if headers["x-test-edition"] != "custom" {
|
||||
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
|
||||
}
|
||||
if headers["x-test-client"] != "cli-refresh" {
|
||||
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-17
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,6 +15,26 @@ func configureRootHelp(root *cobra.Command) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the cobra-default English help command with a localized one so
|
||||
// that both its listing short (shown in `dws --help`) and its own
|
||||
// `dws help --help` long text follow the active locale.
|
||||
root.SetHelpCommand(&cobra.Command{
|
||||
Use: "help [command]",
|
||||
Short: i18n.T("查看任意命令的帮助信息"),
|
||||
Long: i18n.T("显示任意命令的帮助文案。\n" +
|
||||
"用法:dws help [命令路径] 查看完整说明。"),
|
||||
DisableAutoGenTag: true,
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
target, _, err := c.Root().Find(args)
|
||||
if target == nil || err != nil {
|
||||
c.Root().HelpFunc()(c.Root(), args)
|
||||
return
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
_ = target.Help()
|
||||
},
|
||||
})
|
||||
|
||||
defaultHelpFunc := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != root {
|
||||
@@ -63,21 +84,30 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
}
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
// including plugins loaded via AppendDynamicServer — are never silently hidden
|
||||
// by a static VisibleProducts list.
|
||||
func resolveVisibleProducts() map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
for _, p := range fn() {
|
||||
allowed[p] = true
|
||||
}
|
||||
}
|
||||
for id := range DirectRuntimeProductIDs() {
|
||||
allowed[id] = true
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowed map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowed = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowed[p] = true
|
||||
}
|
||||
} else {
|
||||
allowed = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowed := resolveVisibleProducts()
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -100,13 +130,7 @@ func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
productCommands := DirectRuntimeProductIDs()
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
productCommands = make(map[string]bool, len(fn()))
|
||||
for _, product := range fn() {
|
||||
productCommands[product] = true
|
||||
}
|
||||
}
|
||||
productCommands := resolveVisibleProducts()
|
||||
|
||||
commands := make([]*cobra.Command, 0)
|
||||
for _, cmd := range root.Commands() {
|
||||
|
||||
+89
-3
@@ -88,9 +88,21 @@ const (
|
||||
envDingtalkTraceID = "DINGTALK_TRACE_ID"
|
||||
envDingtalkSessionID = "DINGTALK_SESSION_ID"
|
||||
envDingtalkMessageID = "DINGTALK_MESSAGE_ID"
|
||||
|
||||
// Environment variables for third-party channel integration
|
||||
envDWSChannel = "DWS_CHANNEL"
|
||||
)
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
// where no env var has been inherited from a parent process.
|
||||
if os.Getenv("DWS_CLIENT_ID") == "" {
|
||||
if cid := authpkg.ClientID(); cid != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", cid)
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
@@ -156,10 +168,10 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
|
||||
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
@@ -172,6 +184,37 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
// Previously every catalog miss silently fell through to EchoRunner, which
|
||||
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
|
||||
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
|
||||
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
|
||||
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
|
||||
//
|
||||
// New contract:
|
||||
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
|
||||
// `--dry-run` still prints the planned payload without real execution.
|
||||
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible.
|
||||
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
|
||||
if dryRun {
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
|
||||
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
|
||||
if IsStdioEndpoint(endpoint) {
|
||||
@@ -295,19 +338,50 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
}
|
||||
}
|
||||
// PAT scope error: offer human-readable output and retry after authorization
|
||||
if isPatScopeError(err) {
|
||||
scopeErr := extractPatScopeError(err)
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
// ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ----
|
||||
if fn := edition.Get().ClassifyToolResult; fn != nil {
|
||||
if editionErr := fn(callResult.Content); editionErr != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
return executor.Result{}, editionErr
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Structured PAT auth check (open-source fallback) ----
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
|
||||
|
||||
// ClassifyToolResult hook: let the overlay intercept known error
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||
captureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
return executor.Result{}, hookErr
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
@@ -316,6 +390,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return executor.Result{}, mcpErr
|
||||
}
|
||||
@@ -529,7 +609,7 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
@@ -541,6 +621,12 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Inject third-party channel headers
|
||||
if v := os.Getenv(envDWSChannel); v != "" {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
|
||||
+27
-26
@@ -26,6 +26,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
@@ -33,6 +34,13 @@ import (
|
||||
func setupRuntimeCommandTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
// Isolate keychain storage so concurrent test packages can't leak a
|
||||
// real auth token into runtime tests via the shared on-disk keychain
|
||||
// location. We deliberately do NOT reset the process-wide token
|
||||
// cache here: getCachedRuntimeToken uses sync.Once and is read by
|
||||
// detached preload goroutines spawned per Run(); replacing the Once
|
||||
// races with those still-running goroutines.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
|
||||
discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(contactDiscoveryResponse())
|
||||
@@ -336,7 +344,17 @@ func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix
|
||||
// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog
|
||||
// does not carry the requested product (here `contact` against a
|
||||
// doc-only fixture), `runtimeRunner.Run` must surface an explicit
|
||||
// `endpoint_not_resolved` error instead of silently echoing an empty result
|
||||
// back to the user — the latter is what historically produced `{"Content":
|
||||
// null}` output on `dws doc list`.
|
||||
//
|
||||
// Dry-run / compat semantics continue to route through EchoRunner and are
|
||||
// covered by other tests in this file (TestRuntime*Dry*, TestCompat*).
|
||||
func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
@@ -349,33 +367,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Invocation struct {
|
||||
Implemented bool `json:"implemented"`
|
||||
CanonicalProduct string `json:"canonical_product"`
|
||||
Tool string `json:"tool"`
|
||||
} `json:"invocation"`
|
||||
Response map[string]any `json:"response"`
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "endpoint not resolved") {
|
||||
t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg)
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
if payload.Invocation.Implemented {
|
||||
t.Fatalf("implemented = true, want false for fallback")
|
||||
}
|
||||
if payload.Invocation.CanonicalProduct != "contact" {
|
||||
t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct)
|
||||
}
|
||||
if payload.Invocation.Tool != "get_current_user_profile" {
|
||||
t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool)
|
||||
}
|
||||
if payload.Response != nil {
|
||||
t.Fatalf("response = %#v, want nil for echo fallback", payload.Response)
|
||||
if !strings.Contains(msg, "contact") {
|
||||
t.Fatalf("error message = %q, want it to mention product \"contact\"", msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestMain isolates the on-disk keychain storage to a process-wide
|
||||
// temporary directory for the entire internal/app test binary.
|
||||
//
|
||||
// Background: getCachedRuntimeToken caches the auth token via sync.Once
|
||||
// for the process lifetime. Whichever test triggers it first locks in the
|
||||
// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget)
|
||||
// call SaveTokenData and then exec a CLI command that triggers Once.Do; if
|
||||
// keychain storage points at the developer's real ~/Library/Application
|
||||
// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be
|
||||
// written there and cached process-wide, breaking later tests that assume
|
||||
// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest.
|
||||
//
|
||||
// Setting keychain.StorageDirEnv here forces every keychain read/write in
|
||||
// this binary into a per-process tempdir, eliminating that contamination
|
||||
// without touching production code.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -56,6 +57,19 @@ func (a *toolCallerAdapter) DryRun() bool {
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
// After the fix-wukong-discovery-missing-servers Phase 3 change,
|
||||
// runtimeRunner.Run returns an explicit error for catalog misses
|
||||
// instead of an empty Response, so this branch should only be
|
||||
// reachable for unit tests / unexpected runners. Log a warning so
|
||||
// any future regression (silent `{"Content": null}` on the CLI)
|
||||
// leaves a trace in the file logger / stderr.
|
||||
slog.Warn(
|
||||
"tool_caller_adapter: empty runner response — upstream should surface an error instead",
|
||||
"product", r.Invocation.CanonicalProduct,
|
||||
"tool", r.Invocation.Tool,
|
||||
"kind", r.Invocation.Kind,
|
||||
"dry_run", r.Invocation.DryRun,
|
||||
)
|
||||
return &edition.ToolResult{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// overrideVisibleProducts temporarily installs an edition hook exposing the
|
||||
// given static product list and restores the previous hooks on cleanup.
|
||||
func overrideVisibleProducts(t *testing.T, products []string) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
VisibleProducts: func() []string { return products },
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
|
||||
// the product ID ends up in DirectRuntimeProductIDs() without triggering
|
||||
// network discovery.
|
||||
func registerPluginProduct(t *testing.T, id, endpoint string) {
|
||||
t.Helper()
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
|
||||
// is a regression for the dws-wukong plugin-visibility bug: when an edition
|
||||
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
|
||||
// IDs) and a plugin registers a new product via AppendDynamicServer
|
||||
// (e.g. `conference-local`), the plugin command must stay visible because the
|
||||
// dynamic registry takes precedence over the hook's static whitelist.
|
||||
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if calendarCmd.Hidden {
|
||||
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
|
||||
}
|
||||
if !bogusCmd.Hidden {
|
||||
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
|
||||
// renderer surfaces plugin products in the "Discovered MCP Services" section
|
||||
// and does not misclassify them as utility commands.
|
||||
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
authCmd := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, authCmd)
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
|
||||
}
|
||||
if !containsCommand(services, "calendar") {
|
||||
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
|
||||
}
|
||||
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
if containsCommand(utilities, "conference-local") {
|
||||
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
|
||||
}
|
||||
if !containsCommand(utilities, "auth") {
|
||||
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommand(cmds []*cobra.Command, name string) bool {
|
||||
for _, c := range cmds {
|
||||
if c.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandNames(cmds []*cobra.Command) []string {
|
||||
names := make([]string, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -169,7 +167,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !status.Success || !status.Result.CLIAuthEnabled {
|
||||
if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
@@ -190,9 +188,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -204,7 +200,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Result.CLIAuthEnabled {
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
@@ -215,9 +211,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -229,7 +223,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result.CLIAuthEnabled {
|
||||
if status.Result == nil || status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=false")
|
||||
}
|
||||
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
@@ -262,12 +256,10 @@ func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if !cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be true when API returns enabled")
|
||||
}
|
||||
@@ -280,12 +272,10 @@ func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T)
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when server says disabled")
|
||||
}
|
||||
@@ -314,12 +304,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -340,13 +336,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
@@ -378,12 +375,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -396,9 +399,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
||||
@@ -415,13 +416,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
@@ -450,12 +452,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -468,9 +476,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
|
||||
default:
|
||||
@@ -481,13 +487,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetAppConfigCache clears cached app config so tests get a fresh load.
|
||||
func resetAppConfigCache() {
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
cachedAppConfigOnce = sync.Once{}
|
||||
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── Issue #155: Defensive credential reset ────────────────────────────
|
||||
//
|
||||
// These tests verify that both DeviceFlowProvider and OAuthProvider always
|
||||
// reset credential state and re-fetch clientID from MCP, regardless of what
|
||||
// previous login methods left in app.json or runtime state.
|
||||
|
||||
func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: OAuth login saved app.json WITHOUT Source field (the original bug).
|
||||
// Device flow should ignore the stale clientID, reset state, and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate OAuth login: saved app.json with clientId but NO Source field
|
||||
oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
// Verify precondition: ClientID() loads stale value from app.json
|
||||
resetAppConfigCache()
|
||||
gotID := ClientID()
|
||||
if gotID != "ding-oauth-stale" {
|
||||
t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source")
|
||||
}
|
||||
|
||||
// Now create a DeviceFlowProvider — it should pick up the stale clientID
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-oauth-stale" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Key assertion: after prepareCredentials(), the provider should have
|
||||
// cleared the stale clientID and be ready for MCP fetch.
|
||||
// We can't call Login() directly (needs real MCP server), but we can
|
||||
// verify the reset logic by calling the new method directly.
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: Legacy app.json (no Source field at all) exists from an old CLI version.
|
||||
// Device flow should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Write legacy app.json
|
||||
legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-legacy-old" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: User previously logged in with --client-id + --client-secret (direct mode).
|
||||
// app.json has a different clientId. Device flow should reset and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate direct-mode app.json with clientSecret stored
|
||||
if err := SaveAppConfig(dir, &AppConfig{
|
||||
ClientID: "ding-direct-custom",
|
||||
ClientSecret: PlainSecret("some-secret"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify the direct clientID was loaded
|
||||
if provider.clientID != "ding-direct-custom" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set from a previous device login in same process.
|
||||
// Device flow should still reset and re-fetch to ensure freshness.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Simulate: MCP flag is already set from previous login
|
||||
SetClientIDFromMCP("ding-old-mcp")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-old-mcp" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear both clientID and MCP flag
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) {
|
||||
// Scenario: No app.json exists (first time login). Device flow should work normally.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// clientID should already be empty
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should be a no-op but not crash
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuthProvider defensive reset (--force login) ─────────────────────
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) {
|
||||
// Scenario: Previous login saved app.json with MCP-fetched clientID but
|
||||
// no Source marker. OAuth --force login should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate previous login: app.json with clientId but no Source
|
||||
staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-stale-oauth" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set. OAuth --force login should still reset.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientIDFromMCP("ding-old-mcp-oauth")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
+244
-54
@@ -33,32 +33,37 @@ import (
|
||||
|
||||
const (
|
||||
// defaultPollInterval is the default seconds between device token polls.
|
||||
defaultPollInterval = 5
|
||||
// The server-side Redis TTL is 10 minutes; a 2-second interval keeps the
|
||||
// user-perceived latency low while staying well within rate limits.
|
||||
defaultPollInterval = 2
|
||||
// maxPollInterval caps the polling interval to prevent DoS via slow_down.
|
||||
maxPollInterval = 30
|
||||
// maxPollTotalWait caps the total wait time for device authorization.
|
||||
maxPollTotalWait = 15 * time.Minute
|
||||
// Aligned with the server-side Redis TTL (10 minutes).
|
||||
maxPollTotalWait = 10 * time.Minute
|
||||
)
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
terminalBaseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
return &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
terminalBaseURL: GetMCPBaseURL(),
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +71,18 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetScope overrides the OAuth scope for the device flow.
|
||||
func (p *DeviceFlowProvider) SetScope(scope string) {
|
||||
if p != nil {
|
||||
p.scope = scope
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -80,6 +97,7 @@ type DeviceAuthResponse struct {
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
Interval int `json:"interval"`
|
||||
FlowID string `json:"flowId"`
|
||||
}
|
||||
|
||||
type DeviceTokenResponse struct {
|
||||
@@ -88,6 +106,20 @@ type DeviceTokenResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// DevicePollResponse represents the response from the terminal API poll endpoint.
|
||||
type DevicePollResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data DevicePollData `json:"data"`
|
||||
}
|
||||
|
||||
type DevicePollData struct {
|
||||
Status string `json:"status"`
|
||||
AuthCode string `json:"authCode,omitempty"`
|
||||
FlowID string `json:"flowId,omitempty"`
|
||||
}
|
||||
|
||||
type serviceResult struct {
|
||||
Success bool `json:"success"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
@@ -95,22 +127,38 @@ type serviceResult struct {
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, device flow will re-fetch from MCP and
|
||||
// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
|
||||
// the MCP proxy path (which doesn't require clientSecret).
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Ensure we have a valid client ID (fetch from MCP if not available)
|
||||
if p.clientID == "" {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
// Mark that clientID is from MCP
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -176,33 +224,61 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
||||
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
|
||||
// CLI auth is disabled - show detailed error with admin info
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
}
|
||||
denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
if denialReason != "" {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
switch denialReason {
|
||||
case "user_forbidden":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI")))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI"))
|
||||
case "user_not_allowed":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员"))
|
||||
case "channel_not_allowed":
|
||||
ch := os.Getenv("DWS_CHANNEL")
|
||||
_, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch)
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch)
|
||||
case "channel_required":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
||||
case "no_auth":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录"))
|
||||
default:
|
||||
// cli_not_enabled or unknown — show existing admin-apply flow
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
// Try to get super admin list
|
||||
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
// Show up to 3 admins
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
maxAdmins = len(admins.Result)
|
||||
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
maxAdmins = len(admins.Result)
|
||||
}
|
||||
var adminNames []string
|
||||
for i := 0; i < maxAdmins; i++ {
|
||||
adminNames = append(adminNames, admins.Result[i].Name)
|
||||
}
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
||||
}
|
||||
var adminNames []string
|
||||
for i := 0; i < maxAdmins; i++ {
|
||||
adminNames = append(adminNames, admins.Result[i].Name)
|
||||
}
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL())
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
||||
}
|
||||
|
||||
// Save token data with associated client ID for refresh
|
||||
@@ -211,6 +287,13 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
oauthProvider.persistAppConfigIfNeeded()
|
||||
|
||||
@@ -279,7 +362,91 @@ func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode str
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// pollDeviceStatus polls the terminal API for device authorization status.
|
||||
//
|
||||
// Note: The server returns success=false for REJECTED and EXPIRED terminal
|
||||
// states (with a valid data.Status value). These are normal business outcomes,
|
||||
// not transport errors, so we return the response to the caller and let the
|
||||
// status-switch handle them.
|
||||
func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID))
|
||||
body, err := p.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp DevicePollResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
||||
}
|
||||
// REJECTED/EXPIRED carry success=false but have a valid data.Status;
|
||||
// only treat as a real server error when data.Status is empty.
|
||||
if !resp.Success && resp.Data.Status == "" {
|
||||
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
if auth.FlowID == "" {
|
||||
// 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
|
||||
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)
|
||||
|
||||
pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
|
||||
if err != nil {
|
||||
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("poll error", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch pollResp.Data.Status {
|
||||
case StatusApproved:
|
||||
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
||||
return &DeviceTokenResponse{AuthCode: pollResp.Data.AuthCode}, nil
|
||||
case StatusPending:
|
||||
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
||||
case StatusRejected:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
|
||||
case StatusExpired:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("设备授权码已过期"))
|
||||
default:
|
||||
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollResp.Data.Status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -359,6 +526,29 @@ func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, para
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// doGet performs an HTTP GET request and returns the response body.
|
||||
func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// truncateBody returns a string of at most maxLen bytes from body, appending
|
||||
// "...(truncated)" when the content exceeds the limit. This prevents leaking
|
||||
// potentially sensitive response payloads in error messages.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
// Device flow authorization status constants.
|
||||
// Shared across device_flow.go and pat_auth_retry.go to avoid maintaining
|
||||
// string literals in multiple places.
|
||||
const (
|
||||
StatusPending = "PENDING"
|
||||
StatusApproved = "APPROVED"
|
||||
StatusRejected = "REJECTED"
|
||||
StatusExpired = "EXPIRED"
|
||||
StatusCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// ParseDeviceFlowStatus normalizes a raw status string from the device flow
|
||||
// poll response into a canonical status constant. When the server returns an
|
||||
// empty status with success=false, it falls back to StatusExpired (server
|
||||
// error / flow not found).
|
||||
func ParseDeviceFlowStatus(rawStatus string, success bool) string {
|
||||
switch rawStatus {
|
||||
case StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled:
|
||||
return rawStatus
|
||||
default:
|
||||
if rawStatus == "" && !success {
|
||||
return StatusExpired
|
||||
}
|
||||
return rawStatus
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,21 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func newDeviceFlowTestLogger() *slog.Logger {
|
||||
@@ -89,22 +95,42 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "flowId=") {
|
||||
t.Fatal("flowId query parameter should be present")
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
// Return PENDING status
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "final-auth-code"}, "", "")
|
||||
// Return APPROVED status with authCode
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "final-auth-code",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
DeviceCode: "dc-1",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
@@ -117,9 +143,87 @@ 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()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
FlowID: "test-flow-id-2",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
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"}, "", "")
|
||||
}))
|
||||
@@ -132,11 +236,89 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "dc-2",
|
||||
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
|
||||
})
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -38,6 +39,11 @@ func init() {
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CHANNEL",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "第三方渠道编码 (channelCode),如 Qoderwork",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -78,6 +84,14 @@ const (
|
||||
// DeviceGrantType is the grant_type value defined by RFC 8628.
|
||||
DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// Terminal API base URL for developer settings page.
|
||||
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
|
||||
// DevicePollPath is the device flow polling path (used with MCP base URL).
|
||||
DevicePollPath = "/cli/oauth/device/poll"
|
||||
|
||||
// DeveloperSettingsPath is the path to the organization developer settings page.
|
||||
DeveloperSettingsPath = "/fe/old#/developerSettings"
|
||||
|
||||
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
@@ -94,6 +108,19 @@ const (
|
||||
MCPRevokeTokenPath = "/oauth2/revokeToken"
|
||||
)
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
func GetTerminalBaseURL() string {
|
||||
return config.GetTerminalBaseURL()
|
||||
}
|
||||
|
||||
// GetDeveloperSettingsURL returns the full URL to the organization developer
|
||||
// settings page, derived from the terminal base URL.
|
||||
func GetDeveloperSettingsURL() string {
|
||||
return config.GetDeveloperSettingsURL()
|
||||
}
|
||||
|
||||
// GetMCPBaseURL returns the MCP base URL with priority:
|
||||
// 1. ~/.dws/mcp_url file content (for pre-release environment)
|
||||
// 2. Default value (https://mcp.dingtalk.com)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
||||
// instant (preserving access_token and refresh_token), and writes it back. The
|
||||
// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
|
||||
// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
|
||||
// access_token.
|
||||
//
|
||||
// Use this only when the server has rejected the current access_token but the
|
||||
// local expiry has not yet elapsed (zombie token scenario). It does not delete
|
||||
// any token material and is safe to call concurrently — actual refresh is
|
||||
// serialized by lockedRefresh's dual-layer locking.
|
||||
//
|
||||
// Returns the original load error when there is no usable token on disk; a
|
||||
// nil error when there is no access_token to invalidate (no-op).
|
||||
func MarkAccessTokenStale(configDir string) error {
|
||||
data, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil || data.AccessToken == "" {
|
||||
return nil
|
||||
}
|
||||
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
return SaveTokenData(configDir, data)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -59,6 +61,19 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges an authorization code for token data using
|
||||
// the currently configured client credentials. This is a convenience wrapper
|
||||
// around OAuthProvider.exchangeCode for callers outside the auth package.
|
||||
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
Output: io.Discard,
|
||||
httpClient: oauthHTTPClient,
|
||||
}
|
||||
return p.exchangeCode(ctx, code)
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
@@ -919,14 +934,254 @@ const notEnabledHTML = `<!doctype html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const accessDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>无权限访问</h1>
|
||||
<p>您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const channelDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>渠道未授权</h1>
|
||||
<p>当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
||||
type CLIAuthStatus struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result *CLIAuthResult `json:"result"`
|
||||
}
|
||||
|
||||
// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled.
|
||||
// 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"
|
||||
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"
|
||||
}
|
||||
if status.ErrorCode == "NO_AUTH" {
|
||||
return "no_auth"
|
||||
}
|
||||
if status.Result == nil || !status.Success {
|
||||
return "unknown"
|
||||
}
|
||||
r := status.Result
|
||||
if r.CLIAuthEnabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if r.UserScope == "forbidden" {
|
||||
return "user_forbidden"
|
||||
}
|
||||
|
||||
if r.ChannelScope == "specified" {
|
||||
if currentChannel == "" {
|
||||
return "channel_required"
|
||||
}
|
||||
if !slices.Contains(r.AllowedChannels, currentChannel) {
|
||||
return "channel_not_allowed"
|
||||
}
|
||||
}
|
||||
|
||||
if r.UserScope == "specified" {
|
||||
return "user_not_allowed"
|
||||
}
|
||||
return "cli_not_enabled"
|
||||
}
|
||||
|
||||
// SuperAdmin represents a corp super admin.
|
||||
@@ -985,6 +1240,9 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
|
||||
@@ -55,6 +55,16 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -94,21 +104,22 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Ensure we have a valid client ID (fetch from MCP if not available)
|
||||
if p.clientID == "" {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("client ID not configured, fetching from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
// Mark that clientID is from MCP, so we use MCP OAuth endpoints
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
@@ -124,6 +135,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
token *TokenData
|
||||
err error
|
||||
cliAuthDisabled bool
|
||||
denialReason string
|
||||
}
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
errCh := make(chan error, 1)
|
||||
@@ -236,19 +248,30 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
|
||||
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
|
||||
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
var denialReason string
|
||||
if statusErr != nil {
|
||||
denialReason = "unknown"
|
||||
} else {
|
||||
denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
}
|
||||
cliAuthEnabled := denialReason == ""
|
||||
|
||||
// Update CLI auth disabled state
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
// Display appropriate HTML based on CLI auth status
|
||||
// Display appropriate HTML based on auth status and denial reason
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if !cliAuthEnabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
switch {
|
||||
case cliAuthEnabled:
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
case denialReason == "user_forbidden" || denialReason == "user_not_allowed":
|
||||
_, _ = fmt.Fprint(w, accessDeniedHTML)
|
||||
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
|
||||
_, _ = fmt.Fprint(w, channelDeniedHTML)
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
}
|
||||
// Ensure response is flushed to client
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
@@ -256,7 +279,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
@@ -395,8 +418,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
|
||||
}
|
||||
|
||||
// Handle CLI auth disabled - keep server running for user to apply
|
||||
// Handle CLI auth disabled - for terminal denial reasons, exit immediately
|
||||
// (page shows accessDeniedHTML/channelDeniedHTML with no apply button,
|
||||
// so polling for apply submission would hang forever).
|
||||
// Error messages are kept consistent with the text shown on the HTML pages.
|
||||
if result.cliAuthDisabled {
|
||||
switch result.denialReason {
|
||||
case "user_forbidden", "user_not_allowed":
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
|
||||
case "channel_not_allowed", "channel_required":
|
||||
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
|
||||
|
||||
@@ -435,7 +468,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
// Check if CLI auth is now enabled (admin approved)
|
||||
if currentToken != nil {
|
||||
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
|
||||
if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
|
||||
if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" {
|
||||
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
|
||||
time.Sleep(2 * time.Second)
|
||||
result.token = currentToken
|
||||
@@ -463,6 +496,13 @@ continueLogin:
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
|
||||
// Persist app credentials if using custom client credentials
|
||||
p.persistAppConfigIfNeeded()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestMain isolates the on-disk keychain storage to a process-wide
|
||||
// temporary directory for the entire internal/auth test binary so that
|
||||
// SaveTokenData/DeleteTokenData calls in these tests can never write to
|
||||
// the developer's real keychain location, preventing cross-package leaks
|
||||
// when go test runs packages in parallel.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-auth-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -20,9 +20,12 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// cleanupKeychain removes test data from keychain after test completes.
|
||||
// cleanupKeychain isolates keychain state to a per-test temporary directory
|
||||
// so that concurrent test packages (notably internal/app) don't read tokens
|
||||
// written by these tests, and removes test data on completion.
|
||||
func cleanupKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
|
||||
+113
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+34
-1
@@ -47,6 +47,13 @@ func init() {
|
||||
Example: "/path/to/catalog.json",
|
||||
Hidden: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PLUGIN_COLD_TIMEOUT",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "插件 MCP 冷启动发现的超时时长(Go duration 格式,如 2s / 1500ms)。设置后同时覆盖 HTTP 与 stdio 插件的冷启动预算;未设置时使用内置默认值(HTTP 无鉴权 1s / 有鉴权 1.5s / stdio 2s)。",
|
||||
DefaultValue: "",
|
||||
Example: "3s",
|
||||
})
|
||||
}
|
||||
|
||||
// CatalogDegradedReason identifies why catalog discovery returned empty.
|
||||
@@ -104,10 +111,13 @@ func newCatalogDegraded(reason CatalogDegradedReason, serverCount int) *CatalogD
|
||||
const (
|
||||
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
|
||||
CacheDirEnv = "DWS_CACHE_DIR"
|
||||
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
|
||||
DefaultMarketBaseURL = "https://mcp.dingtalk.com"
|
||||
|
||||
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
|
||||
defaultDiscoveryTimeout = 10 * time.Second
|
||||
// Tightened to 4s so a slow/unreachable discovery endpoint cannot block
|
||||
// every CLI command invocation. See issue #119.
|
||||
defaultDiscoveryTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
type CatalogLoader interface {
|
||||
@@ -240,6 +250,13 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
|
||||
}
|
||||
|
||||
// Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps,
|
||||
// dangling toolOverrides.*.serverOverride) to stderr so configuration drift
|
||||
// is visible at the first `dws cache refresh` / cold start after Portal
|
||||
// publishes a broken envelope. Non-fatal: discovery continues with the
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
@@ -344,3 +361,19 @@ func (l EnvironmentLoader) lookup(key string) (string, bool) {
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr).
|
||||
// No-op when the response carries an empty / nil Warnings slice — which is the
|
||||
// common case for older Portal builds that don't populate the field.
|
||||
func logDiscoveryWarnings(warnings []market.ListWarning) {
|
||||
if len(warnings) == 0 {
|
||||
return
|
||||
}
|
||||
for _, w := range warnings {
|
||||
slog.Warn("discovery: merge warning from Portal",
|
||||
"product", w.ProductID,
|
||||
"reason", w.Reason,
|
||||
"detail", w.Detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,37 +14,18 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
const overridePriorityAnnotation = "dws.override-priority"
|
||||
|
||||
// SetOverridePriority sets the override priority annotation on cmd.
|
||||
// SetOverridePriority delegates to cmdutil.SetOverridePriority so internal and
|
||||
// external call sites share one annotation key.
|
||||
func SetOverridePriority(cmd *cobra.Command, priority int) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[overridePriorityAnnotation] = strconv.Itoa(priority)
|
||||
cmdutil.SetOverridePriority(cmd, priority)
|
||||
}
|
||||
|
||||
// OverridePriority returns the override priority annotation value, or 0.
|
||||
// OverridePriority delegates to cmdutil.OverridePriority.
|
||||
func OverridePriority(cmd *cobra.Command) int {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cmd.Annotations[overridePriorityAnnotation])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
return cmdutil.OverridePriority(cmd)
|
||||
}
|
||||
|
||||
@@ -14,18 +14,35 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runtimeDefaultWhitelist is the closed set of placeholders v3 supports for
|
||||
// CLIFlagOverride.RuntimeDefault. Placeholders outside this set emit a
|
||||
// warning at command-build time and are ignored at invocation time. See
|
||||
// discovery-schema-v3 §2.3.
|
||||
var runtimeDefaultWhitelist = map[string]bool{
|
||||
"$currentUserId": true,
|
||||
"$unionId": true,
|
||||
"$corpId": true,
|
||||
"$now": true,
|
||||
"$today": true,
|
||||
}
|
||||
|
||||
// BuildDynamicCommands generates cobra commands from servers.json CLIOverlay metadata.
|
||||
// Each server with non-skip CLIOverlay gets a top-level command with groups and
|
||||
// tool overrides translated into subcommands with proper flag bindings and transforms.
|
||||
@@ -47,10 +64,6 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
if cli.Skip {
|
||||
continue
|
||||
}
|
||||
if len(cli.ToolOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// §1.1: cli.command → top-level command name
|
||||
cmdName := strings.TrimSpace(cli.Command)
|
||||
if cmdName == "" {
|
||||
@@ -60,11 +73,35 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
continue
|
||||
}
|
||||
|
||||
// §v3.2.6: CLIOverlay.RedirectTo turns the entire top-level product
|
||||
// into a stub that prints "Please use: dws <target>".
|
||||
if target := strings.TrimSpace(cli.RedirectTo); target != "" {
|
||||
stub := buildOverlayRedirect(cmdName, cli.Description, target)
|
||||
if cli.Hidden {
|
||||
stub.Hidden = true
|
||||
}
|
||||
// Mark envelope provenance so overlay registrants can tell this
|
||||
// redirect stub apart from a helper fallback carrying the same
|
||||
// name; see cmdutil.SourceAnnotation.
|
||||
cmdutil.MarkEnvelopeSource(stub)
|
||||
built = append(built, builtCmd{cmd: stub, parent: strings.TrimSpace(cli.Parent)})
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cli.ToolOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rootCmd := NewGroupCommand(cmdName, cli.Description)
|
||||
// §1.5: cli.hidden → entire service hidden
|
||||
if cli.Hidden {
|
||||
rootCmd.Hidden = true
|
||||
}
|
||||
// Mark envelope provenance so edition overlays can distinguish this
|
||||
// dynamic root from a same-named helper fallback when deciding
|
||||
// whether to merge hardcoded leaves or evict and replace. See
|
||||
// cmdutil.SourceAnnotation and the wukong overlay's RegisterProducts.
|
||||
cmdutil.MarkEnvelopeSource(rootCmd)
|
||||
|
||||
// Build detail index for this server: toolName → DetailTool
|
||||
detailIndex := buildDetailIndex(detailsByID[strings.TrimSpace(cli.ID)])
|
||||
@@ -93,6 +130,13 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
cliName = deriveCommandName(toolName, cli.Prefixes)
|
||||
}
|
||||
|
||||
// §P2.redirect: redirectTo turns this entry into a stub.
|
||||
if target := strings.TrimSpace(override.RedirectTo); target != "" {
|
||||
redirect := buildRedirectCommand(cliName, override.Description, target)
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, redirect)
|
||||
continue
|
||||
}
|
||||
|
||||
bindings, normalizer := buildOverrideBindings(override)
|
||||
|
||||
// Resolve Short/Long from Detail API toolTitle/toolDesc;
|
||||
@@ -111,12 +155,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
}
|
||||
}
|
||||
|
||||
// ServerOverride routes this leaf's tool invocation to a different
|
||||
// product's MCP server; fall back to the enclosing overlay's ID.
|
||||
canonicalProduct := strings.TrimSpace(override.ServerOverride)
|
||||
if canonicalProduct == "" {
|
||||
canonicalProduct = strings.TrimSpace(cli.ID)
|
||||
}
|
||||
|
||||
route := Route{
|
||||
Use: cliName,
|
||||
Short: short,
|
||||
Long: long,
|
||||
// Preserve left-side indentation: cobra's Examples template
|
||||
// renders {{.Example}} verbatim, and hardcoded helper commands
|
||||
// rely on a 2-space prefix to look indented under "Examples:".
|
||||
// Only trim trailing whitespace/newlines so envelope JSON can
|
||||
// safely carry a closing "\n" without doubling the blank line.
|
||||
Example: strings.TrimRight(override.Example, " \t\r\n"),
|
||||
Target: Target{
|
||||
CanonicalProduct: strings.TrimSpace(cli.ID),
|
||||
CanonicalProduct: canonicalProduct,
|
||||
Tool: toolName,
|
||||
},
|
||||
Bindings: bindings,
|
||||
@@ -128,6 +185,9 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
route.Normalizer = chainSensitiveNormalizer(normalizer)
|
||||
}
|
||||
|
||||
// §v3.2.5: outputFormat.rename/drop/columns post-processing.
|
||||
route.OutputTransform = buildOutputTransform(override.OutputFormat)
|
||||
|
||||
cmd := NewDirectCommand(route, runner)
|
||||
|
||||
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
|
||||
@@ -135,13 +195,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
|
||||
}
|
||||
|
||||
// §P2.flagconstraints: must run AFTER schema enrichment because the
|
||||
// target flags may be registered lazily by buildFlagsFromDetailSchema.
|
||||
applyFlagConstraints(cmd, override)
|
||||
|
||||
// §1.4: Add to the right parent group
|
||||
groupName := strings.TrimSpace(override.Group)
|
||||
if groupName != "" {
|
||||
parent := resolveNestedGroup(rootCmd, groupName, groupCmds)
|
||||
parent.AddCommand(cmd)
|
||||
} else {
|
||||
rootCmd.AddCommand(cmd)
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
|
||||
}
|
||||
|
||||
// §P2.hints: attach hint stub commands registered on the overlay.
|
||||
if len(cli.Hints) > 0 {
|
||||
hintNames := make([]string, 0, len(cli.Hints))
|
||||
for name := range cli.Hints {
|
||||
hintNames = append(hintNames, name)
|
||||
}
|
||||
sort.Strings(hintNames)
|
||||
for _, name := range hintNames {
|
||||
def := cli.Hints[name]
|
||||
hint := buildHintCommand(name, def)
|
||||
attachToGroup(rootCmd, def.Group, groupCmds, hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +238,7 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
}
|
||||
for _, child := range children {
|
||||
if parent, ok := topLevel[child.parent]; ok {
|
||||
parent.AddCommand(child.cmd)
|
||||
attachOrMerge(parent, child.cmd)
|
||||
} else {
|
||||
// Parent not found among dynamic commands; emit as top-level.
|
||||
name := child.cmd.Name()
|
||||
@@ -238,6 +310,15 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
|
||||
for _, key := range keys {
|
||||
prop := schema.Properties[key]
|
||||
|
||||
// Skip properties that are bound as positional arguments; they are
|
||||
// collected from cobra args rather than flags. For dual-mode positional
|
||||
// bindings (envelope: positional + alias/aliases), ApplyBindings has
|
||||
// already registered the alias flags, so we should not re-register
|
||||
// them here from the MCP detail schema.
|
||||
if ov, ok := flagOverrides[key]; ok && ov.Positional {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine flag name: prefer alias from CLIFlagOverride, else kebab-case.
|
||||
flagName := toKebabCase(key)
|
||||
if ov, ok := flagOverrides[key]; ok && strings.TrimSpace(ov.Alias) != "" {
|
||||
@@ -389,6 +470,43 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
|
||||
return ensureNestedGroup(root, groupPath, groupPath, registry)
|
||||
}
|
||||
|
||||
// attachOrMerge adds child as a sub-command of parent. If parent already has a
|
||||
// sub-command with the same Name(), the two are merged recursively: child's
|
||||
// sub-commands are moved onto the existing one and child itself is discarded.
|
||||
// Leaf collisions (two commands with the same Name and no further children)
|
||||
// are resolved first-wins — the incoming one is dropped.
|
||||
//
|
||||
// This lets multiple server entries share a cli.command under the same parent,
|
||||
// e.g. bot-message (command="message", parent="chat") can contribute leaves
|
||||
// into the same "message" subtree already built from chat's own toolOverrides,
|
||||
// without creating a duplicate "message" sibling in chat's help output.
|
||||
func attachOrMerge(parent, child *cobra.Command) {
|
||||
existing := findSubcommand(parent, child.Name())
|
||||
if existing == nil {
|
||||
parent.AddCommand(child)
|
||||
return
|
||||
}
|
||||
// Snapshot child's sub-commands before we start moving them (RemoveCommand
|
||||
// mutates the slice we'd be iterating).
|
||||
subs := make([]*cobra.Command, len(child.Commands()))
|
||||
copy(subs, child.Commands())
|
||||
for _, sub := range subs {
|
||||
child.RemoveCommand(sub)
|
||||
attachOrMerge(existing, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// findSubcommand returns the first sub-command of parent with the given name,
|
||||
// or nil if none match.
|
||||
func findSubcommand(parent *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range parent.Commands() {
|
||||
if sub.Name() == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOverrideBindings converts CLIToolOverride flags into FlagBindings and
|
||||
// constructs a Normalizer that applies transform rules.
|
||||
// Implements §2.1-§2.5 of the conversion rules.
|
||||
@@ -415,21 +533,46 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
envVar string
|
||||
}
|
||||
var envDefaults []envDefaultEntry
|
||||
type hiddenDefaultEntry struct {
|
||||
// defaultInjectEntry captures envelope flag.default values that must be
|
||||
// injected into the MCP body when the user omits the flag. v3.2 widened
|
||||
// this from hidden-only to all flags so that visible flags carrying a
|
||||
// default (e.g. oa list-forms cursor=0 / pageSize=100) match the
|
||||
// hardcoded helper command behavior of `mustGetFlag(cobra default) →
|
||||
// body`. The kind drives typed coercion at injection time so a
|
||||
// `type: int` envelope default reaches MCP as `int(0)`, not string `"0"`.
|
||||
type defaultInjectEntry struct {
|
||||
paramName string
|
||||
defaultValue string
|
||||
kind ValueKind
|
||||
}
|
||||
var hiddenDefaults []hiddenDefaultEntry
|
||||
var defaultInjects []defaultInjectEntry
|
||||
type runtimeDefaultEntry struct {
|
||||
paramName string
|
||||
placeholder string
|
||||
}
|
||||
var runtimeDefaults []runtimeDefaultEntry
|
||||
type omitEntry struct {
|
||||
paramName string
|
||||
mode string // "empty" (default) | "zero" | "never"
|
||||
}
|
||||
omits := make(map[string]omitEntry, len(paramNames))
|
||||
|
||||
for _, paramName := range paramNames {
|
||||
flagOverride := override.Flags[paramName]
|
||||
|
||||
// §2.2: flag name from alias, fallback to kebab-case of param name
|
||||
flagName := strings.TrimSpace(flagOverride.Alias)
|
||||
if flagName == "" {
|
||||
// §2.2: flag name from alias, fallback to kebab-case of param name.
|
||||
// For pure positional bindings (no alias declared) we deliberately
|
||||
// leave FlagName empty so ApplyBindings / NewDirectCommand can
|
||||
// distinguish "envelope wants flag-or-positional dual entry" from
|
||||
// "envelope only wants positional". Auto-deriving a flag name here
|
||||
// would otherwise leak a redundant `--<paramName>` flag and confuse
|
||||
// the dual-mode detection.
|
||||
explicitAlias := strings.TrimSpace(flagOverride.Alias)
|
||||
flagName := explicitAlias
|
||||
if flagName == "" && !flagOverride.Positional {
|
||||
flagName = compatFlagName(paramName)
|
||||
}
|
||||
if flagName == "" {
|
||||
if flagName == "" && !flagOverride.Positional {
|
||||
flagName = paramName
|
||||
}
|
||||
|
||||
@@ -438,23 +581,68 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
continue
|
||||
}
|
||||
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Property: paramName,
|
||||
Kind: ValueString,
|
||||
Usage: paramName,
|
||||
// §2.2 (aliases): deduplicate additional hidden aliases against the
|
||||
// primary name + reserved names. Preserves envelope declaration order
|
||||
// so CLI precedence (primary > Alias > Aliases[0..n]) is deterministic.
|
||||
var extraAliases []string
|
||||
if len(flagOverride.Aliases) > 0 {
|
||||
seen := map[string]bool{"json": true, "params": true}
|
||||
if flagName != "" {
|
||||
seen[flagName] = true
|
||||
}
|
||||
extraAliases = make([]string, 0, len(flagOverride.Aliases))
|
||||
for _, a := range flagOverride.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extraAliases = append(extraAliases, a)
|
||||
}
|
||||
}
|
||||
|
||||
// §2.5: hidden flag with default
|
||||
if flagOverride.Hidden {
|
||||
// Hidden flags are still added but marked hidden.
|
||||
// They are auto-populated with their default value via the normalizer.
|
||||
if flagOverride.Default != "" {
|
||||
hiddenDefaults = append(hiddenDefaults, hiddenDefaultEntry{
|
||||
paramName: paramName,
|
||||
defaultValue: flagOverride.Default,
|
||||
})
|
||||
}
|
||||
// Usage defaults to paramName but an explicit Description on the
|
||||
// overlay wins (it also beats the Detail API's toolDesc during flag
|
||||
// enrichment because buildFlagsFromDetailSchema preserves overlay usage).
|
||||
usage := paramName
|
||||
if desc := strings.TrimSpace(flagOverride.Description); desc != "" {
|
||||
usage = desc
|
||||
}
|
||||
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Aliases: extraAliases,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §P1: Required is preserved for positional bindings too. For
|
||||
// pure positional, cobra arity (MinimumNArgs) enforces presence
|
||||
// at parse time. For dual-mode positional (positional + alias),
|
||||
// validateRequiredPositionalBindings closes the loop in RunE
|
||||
// after both flag and positional injection, so MarkFlagRequired
|
||||
// is intentionally avoided.
|
||||
Required: flagOverride.Required,
|
||||
// §2.4: Default drives both cobra's --help "(default ...)"
|
||||
// rendering and (since v3.2) MCP body injection when the user
|
||||
// omits the flag. CollectBindings still gates writes by
|
||||
// user-changed flags, so user-provided values always win; the
|
||||
// normalizer's defaultInjects loop only fills missing keys.
|
||||
Default: flagOverride.Default,
|
||||
Positional: flagOverride.Positional,
|
||||
PositionalIndex: flagOverride.PositionalIndex,
|
||||
}
|
||||
|
||||
// §v3.2: any non-empty default — hidden or visible — gets injected
|
||||
// when the user omits the flag. Earlier versions gated this on
|
||||
// flagOverride.Hidden which left visible flags with `"default": "0"`
|
||||
// (e.g. oa list-forms cursor) silently absent from the MCP body.
|
||||
if flagOverride.Default != "" {
|
||||
defaultInjects = append(defaultInjects, defaultInjectEntry{
|
||||
paramName: paramName,
|
||||
defaultValue: flagOverride.Default,
|
||||
kind: binding.Kind,
|
||||
})
|
||||
}
|
||||
|
||||
bindings = append(bindings, binding)
|
||||
@@ -472,10 +660,23 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
envVar: flagOverride.EnvDefault,
|
||||
})
|
||||
}
|
||||
if rd := strings.TrimSpace(flagOverride.RuntimeDefault); rd != "" {
|
||||
if !runtimeDefaultWhitelist[rd] {
|
||||
fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: unknown placeholder %q on %s; ignoring\n", rd, paramName)
|
||||
} else {
|
||||
runtimeDefaults = append(runtimeDefaults, runtimeDefaultEntry{
|
||||
paramName: paramName,
|
||||
placeholder: rd,
|
||||
})
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(flagOverride.OmitWhen); mode != "" && mode != "empty" {
|
||||
omits[paramName] = omitEntry{paramName: paramName, mode: mode}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need a normalizer: transforms, env defaults, hidden defaults,
|
||||
// or dotted property paths that need nesting.
|
||||
// runtime defaults, omit-when overrides, dotted property paths, or body wrapper.
|
||||
needsDottedNesting := false
|
||||
for _, b := range bindings {
|
||||
if strings.Contains(b.Property, ".") {
|
||||
@@ -483,16 +684,32 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(hiddenDefaults) == 0 && !needsDottedNesting {
|
||||
bodyWrapper := strings.TrimSpace(override.BodyWrapper)
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// Build a normalizer that applies hidden defaults + env defaults + transforms + nesting
|
||||
// Build a normalizer that applies default injections + env defaults + runtime defaults
|
||||
// + transforms + omitWhen + nesting + body wrap.
|
||||
normalizer := func(cmd *cobra.Command, params map[string]any) error {
|
||||
// §2.5: Apply hidden flag defaults for parameters not explicitly set
|
||||
for _, hd := range hiddenDefaults {
|
||||
if _, exists := params[hd.paramName]; !exists {
|
||||
params[hd.paramName] = hd.defaultValue
|
||||
// §v3.2: Apply envelope flag.default for parameters not explicitly set.
|
||||
// Coerce by Kind so number-typed schemas don't reject string defaults.
|
||||
for _, di := range defaultInjects {
|
||||
if _, exists := params[di.paramName]; exists {
|
||||
continue
|
||||
}
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(di.kind, di.defaultValue)
|
||||
switch di.kind {
|
||||
case ValueInt:
|
||||
params[di.paramName] = defInt
|
||||
case ValueFloat:
|
||||
params[di.paramName] = defFloat
|
||||
case ValueBool:
|
||||
params[di.paramName] = defBool
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
params[di.paramName] = defSlice
|
||||
default: // ValueString, ValueJSON, and any unknown kind
|
||||
params[di.paramName] = defStr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +722,26 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
|
||||
// §v3.2.3: Apply runtime defaults (lowest priority, last default fill).
|
||||
if len(runtimeDefaults) > 0 {
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
for _, rd := range runtimeDefaults {
|
||||
if _, exists := params[rd.paramName]; exists {
|
||||
continue
|
||||
}
|
||||
resolver, ok := resolvers[rd.placeholder]
|
||||
if !ok {
|
||||
// Whitelisted but no provider registered (common on
|
||||
// open-source core). Emit a single warning and move on.
|
||||
fmt.Fprintf(os.Stderr, "[discovery] runtimeDefault: no resolver registered for %s; skipping %s\n", rd.placeholder, rd.paramName)
|
||||
continue
|
||||
}
|
||||
if val, ok := resolver(cmd.Context()); ok && val != "" {
|
||||
params[rd.paramName] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §3: Apply transforms
|
||||
for _, t := range transforms {
|
||||
val, exists := params[t.paramName]
|
||||
@@ -524,15 +761,155 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
params[t.paramName] = transformed
|
||||
}
|
||||
|
||||
// §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
|
||||
// condition for the declared mode. Default mode "empty" is already
|
||||
// handled implicitly by CollectBindings (empty string / empty slice
|
||||
// never enters params), so we only deal with "zero" and "never".
|
||||
for _, o := range omits {
|
||||
applyOmitWhen(params, o.paramName, o.mode)
|
||||
}
|
||||
|
||||
// Nest dotted property paths: "Body.query" → params["Body"]["query"]
|
||||
nestDottedPaths(params)
|
||||
|
||||
// §P2.bodyWrapper: wrap user-facing params under a single named key.
|
||||
// Internal control keys (prefixed with '_' e.g. _blocked, _yes) stay
|
||||
// at the top level so downstream confirmation logic keeps working.
|
||||
if bodyWrapper != "" {
|
||||
wrapParamsIntoBody(params, bodyWrapper)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return bindings, normalizer
|
||||
}
|
||||
|
||||
// wrapParamsIntoBody moves every non-internal key from params into a new
|
||||
// map stored under params[wrapper]. Internal keys (leading underscore) are
|
||||
// preserved at the top level so the dispatcher / --yes logic still sees
|
||||
// them. If params already contains params[wrapper] it is merged in first.
|
||||
func wrapParamsIntoBody(params map[string]any, wrapper string) {
|
||||
if wrapper == "" {
|
||||
return
|
||||
}
|
||||
body := map[string]any{}
|
||||
if existing, ok := params[wrapper].(map[string]any); ok {
|
||||
for k, v := range existing {
|
||||
body[k] = v
|
||||
}
|
||||
delete(params, wrapper)
|
||||
}
|
||||
for key, value := range params {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
body[key] = value
|
||||
delete(params, key)
|
||||
}
|
||||
params[wrapper] = body
|
||||
}
|
||||
|
||||
// attachToGroup places cmd under the right parent based on the dotted group
|
||||
// path. Empty group means attach directly to the overlay root.
|
||||
func attachToGroup(root *cobra.Command, groupPath string, groupCmds map[string]*cobra.Command, cmd *cobra.Command) {
|
||||
gp := strings.TrimSpace(groupPath)
|
||||
if gp == "" {
|
||||
root.AddCommand(cmd)
|
||||
return
|
||||
}
|
||||
parent := resolveNestedGroup(root, gp, groupCmds)
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// buildRedirectCommand returns a stub leaf that prints "use: <target>" and
|
||||
// performs no tool invocation. Accepts unknown flags/args so users hitting
|
||||
// the old path get the redirect message instead of a parse error.
|
||||
func buildRedirectCommand(name, description, target string) *cobra.Command {
|
||||
short := strings.TrimSpace(description)
|
||||
if short == "" {
|
||||
short = fmt.Sprintf("moved → %s", target)
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: fmt.Sprintf("This command has moved. Please use: %s", target),
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "This command has moved. Please use: %s\n", target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildHintCommand returns a stub sub-command that prints a redirect hint
|
||||
// to the canonical command path declared by the overlay's hintCommands entry.
|
||||
func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command {
|
||||
target := strings.TrimSpace(def.Target)
|
||||
short := strings.TrimSpace(def.Description)
|
||||
if short == "" {
|
||||
if target != "" {
|
||||
short = fmt.Sprintf("hint: use %s", target)
|
||||
} else {
|
||||
short = "hint: see --help for the canonical command"
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: short,
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if target != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Please use: %s\n", target)
|
||||
} else {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// applyFlagConstraints wires mutuallyExclusive / requireOneOf declarations
|
||||
// onto the cobra command. Unknown flag names are logged and skipped so a
|
||||
// stale/malformed overlay never blocks the entire command tree from building.
|
||||
func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) {
|
||||
validate := func(names []string) []string {
|
||||
valid := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Lookup(n) == nil {
|
||||
fmt.Fprintf(os.Stderr, "[discovery] flag constraint references unknown flag --%s on %q; skipping\n", n, cmd.Name())
|
||||
return nil
|
||||
}
|
||||
valid = append(valid, n)
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
for _, group := range override.MutuallyExclusive {
|
||||
names := validate(group)
|
||||
if len(names) < 2 {
|
||||
continue
|
||||
}
|
||||
cmd.MarkFlagsMutuallyExclusive(names...)
|
||||
}
|
||||
for _, group := range override.RequireOneOf {
|
||||
names := validate(group)
|
||||
if len(names) < 1 {
|
||||
continue
|
||||
}
|
||||
cmd.MarkFlagsOneRequired(names...)
|
||||
}
|
||||
}
|
||||
|
||||
// chainSensitiveNormalizer wraps a normalizer with --yes confirmation for sensitive operations (§5.1).
|
||||
func chainSensitiveNormalizer(inner Normalizer) Normalizer {
|
||||
return func(cmd *cobra.Command, params map[string]any) error {
|
||||
@@ -581,6 +958,101 @@ func sortedToolNames(m map[string]market.CLIToolOverride) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// kindFromTypeName maps the v3 CLIFlagOverride.Type declaration to the
|
||||
// internal FlagBinding.Kind enum. Empty / unknown → ValueString (which keeps
|
||||
// the v2 behaviour where every overlay flag was a plain string).
|
||||
func kindFromTypeName(typeName string) ValueKind {
|
||||
switch strings.TrimSpace(strings.ToLower(typeName)) {
|
||||
case "int", "integer", "number":
|
||||
return ValueInt
|
||||
case "bool", "boolean":
|
||||
return ValueBool
|
||||
case "stringslice", "string_slice", "[]string":
|
||||
return ValueStringSlice
|
||||
case "string", "":
|
||||
return ValueString
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "[discovery] flag type %q not recognised; defaulting to string\n", typeName)
|
||||
return ValueString
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeDefaultResolvers returns the edition-provided resolver map plus
|
||||
// built-in fallbacks for $now / $today (which are trivially local). Overlays
|
||||
// are expected to register the user-identity placeholders; $now / $today are
|
||||
// always available.
|
||||
func runtimeDefaultResolvers() map[string]edition.RuntimeDefaultFn {
|
||||
resolvers := make(map[string]edition.RuntimeDefaultFn, len(runtimeDefaultWhitelist))
|
||||
resolvers["$now"] = func(ctx context.Context) (string, bool) {
|
||||
return fmt.Sprintf("%d", time.Now().UnixMilli()), true
|
||||
}
|
||||
resolvers["$today"] = func(ctx context.Context) (string, bool) {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
return time.Now().In(loc).Format("2006-01-02"), true
|
||||
}
|
||||
if hooks := edition.Get(); hooks != nil && hooks.RuntimeDefaults != nil {
|
||||
for id, fn := range hooks.RuntimeDefaults() {
|
||||
if fn != nil {
|
||||
resolvers[id] = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
// applyOmitWhen drops a key from params when its value meets the omit
|
||||
// condition for the declared mode. "empty" (the default) is handled by
|
||||
// CollectBindings upstream, so this function only processes "zero" and
|
||||
// "never" — "never" is a marker that keeps the zero value explicit, so we
|
||||
// do nothing for it.
|
||||
func applyOmitWhen(params map[string]any, key, mode string) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "never":
|
||||
return
|
||||
case "zero":
|
||||
val, exists := params[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
if isZeroValue(val) {
|
||||
delete(params, key)
|
||||
}
|
||||
default:
|
||||
// "empty" is the default, no-op.
|
||||
}
|
||||
// Emit a trace for anyone debugging envelope behaviour; kept at Debug so
|
||||
// it never leaks into the default CLI output.
|
||||
slog.Debug("applyOmitWhen", "key", key, "mode", mode)
|
||||
}
|
||||
|
||||
func isZeroValue(v any) bool {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(val) == ""
|
||||
case bool:
|
||||
return !val
|
||||
case int:
|
||||
return val == 0
|
||||
case int64:
|
||||
return val == 0
|
||||
case float64:
|
||||
return val == 0
|
||||
case []any:
|
||||
return len(val) == 0
|
||||
case []string:
|
||||
return len(val) == 0
|
||||
case map[string]any:
|
||||
return len(val) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// nestDottedPaths converts flat dotted keys in params into nested maps.
|
||||
// Example: params["Body.query"] = "test" → params["Body"] = map{"query": "test"}
|
||||
// If multiple dotted keys share a prefix, they are merged into the same nested map.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildOverlayRedirect builds a top-level product command whose only behaviour
|
||||
// is printing "Please use: dws <target>" and returning nil. All args/flags are
|
||||
// accepted and ignored so users running the old command path get the redirect
|
||||
// message instead of a parse error.
|
||||
//
|
||||
// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo).
|
||||
func buildOverlayRedirect(name, description, target string) *cobra.Command {
|
||||
target = strings.TrimSpace(target)
|
||||
short := strings.TrimSpace(description)
|
||||
if short == "" {
|
||||
if target != "" {
|
||||
short = fmt.Sprintf("moved → %s", target)
|
||||
} else {
|
||||
short = "command relocated; see --help for the canonical path"
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: fmt.Sprintf("This command has moved. Please use: dws %s", target),
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if target == "" {
|
||||
_ = cmd.Help()
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping
|
||||
// function applied to executor.Result.Response before the formatter runs.
|
||||
// See discovery-schema-v3 §2.5.
|
||||
//
|
||||
// Apply order (deterministic): drop → rename → columns.
|
||||
// Columns filter only takes effect under --format=table (the formatter
|
||||
// consults it via the "_columns" marker key this function injects).
|
||||
// Returns nil when the spec is empty so callers can skip wiring.
|
||||
func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any {
|
||||
if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
dropped := append([]string(nil), spec.Drop...)
|
||||
renamed := make(map[string]string, len(spec.Rename))
|
||||
for k, v := range spec.Rename {
|
||||
renamed[k] = v
|
||||
}
|
||||
columns := append([]string(nil), spec.Columns...)
|
||||
|
||||
return func(resp map[string]any) map[string]any {
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
applyDrop(resp, dropped)
|
||||
applyRename(resp, renamed)
|
||||
if len(columns) > 0 {
|
||||
resp["_columns"] = append([]string(nil), columns...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
||||
// applyDrop removes the named keys at the top level and one level of nested
|
||||
// object. Missing keys are silently ignored. Keys with "." are treated as a
|
||||
// two-part path (parent.child).
|
||||
func applyDrop(m map[string]any, keys []string) {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(m, key)
|
||||
}
|
||||
for _, v := range m {
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(inner, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRename moves fields from src key to dst key at top level and one level
|
||||
// of nested object. Collisions overwrite silently. Missing src keys are
|
||||
// no-ops.
|
||||
func applyRename(m map[string]any, mapping map[string]string) {
|
||||
if len(mapping) == 0 {
|
||||
return
|
||||
}
|
||||
// First pass: top level.
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if v, ok := m[src]; ok {
|
||||
m[dst] = v
|
||||
delete(m, src)
|
||||
}
|
||||
}
|
||||
// Second pass: one level nested.
|
||||
for _, v := range m {
|
||||
inner, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if val, ok := inner[src]; ok {
|
||||
inner[dst] = val
|
||||
delete(inner, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+447
-38
@@ -56,11 +56,27 @@ type Target struct {
|
||||
type FlagBinding struct {
|
||||
FlagName string
|
||||
Alias string
|
||||
// Aliases are additional hidden flag names that map to the same MCP
|
||||
// parameter. Any of them being set satisfies Required, and the value
|
||||
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
|
||||
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
|
||||
Aliases []string
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
// Default is the cobra-level flag default value as a string. Parsed
|
||||
// into the Kind-appropriate primitive at registration time. Empty
|
||||
// string keeps the existing zero-value default. This only affects
|
||||
// what cobra renders in --help (the "(default ...)" suffix); it does
|
||||
// NOT inject the value into MCP params on its own — CollectBindings
|
||||
// still gates writes by user-changed flags via firstChangedFlag.
|
||||
Default string
|
||||
// Positional binds this parameter to a positional CLI argument rather
|
||||
// than a --flag. PositionalIndex is the 0-based slot.
|
||||
Positional bool
|
||||
PositionalIndex int
|
||||
}
|
||||
|
||||
type Normalizer func(cmd *cobra.Command, params map[string]any) error
|
||||
@@ -75,6 +91,10 @@ type Route struct {
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
Normalizer Normalizer
|
||||
// OutputTransform, when non-nil, post-processes the MCP response payload
|
||||
// (rename / drop / columns) before the formatter emits it. Wired up from
|
||||
// CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5.
|
||||
OutputTransform func(map[string]any) map[string]any
|
||||
}
|
||||
|
||||
type CommandFactory func(runner executor.Runner) *cobra.Command
|
||||
@@ -109,14 +129,87 @@ func NewFallbackCommands(runner executor.Runner) []*cobra.Command {
|
||||
var NewGroupCommand = cobracmd.NewGroupCommand
|
||||
|
||||
func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
// Compute positional arity. Two counts:
|
||||
// - totalMax: the highest PositionalIndex+1 across all positional bindings
|
||||
// (caps how many trailing args cobra accepts).
|
||||
// - strictMin: the highest PositionalIndex+1 among "pure" positional
|
||||
// bindings (no flag aliases). Backward-compat: any pure positional
|
||||
// binding implies required arity at parse time, regardless of Required.
|
||||
//
|
||||
// Dual-mode positional bindings (positional + envelope-declared flag
|
||||
// aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`)
|
||||
// are counted into totalMax but excluded from strictMin so a flag-only
|
||||
// invocation parses; their required-presence is enforced by
|
||||
// validateRequiredPositionalBindings inside RunE.
|
||||
//
|
||||
// For positional bindings, buildOverrideBindings populates FlagName /
|
||||
// Aliases only when the envelope explicitly declared them, so the
|
||||
// dual-mode detection here is unambiguous.
|
||||
strictMin := 0
|
||||
totalMax := 0
|
||||
for _, b := range route.Bindings {
|
||||
if !b.Positional {
|
||||
continue
|
||||
}
|
||||
if b.PositionalIndex+1 > totalMax {
|
||||
totalMax = b.PositionalIndex + 1
|
||||
}
|
||||
hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0
|
||||
if !hasFlagAlias && b.PositionalIndex+1 > strictMin {
|
||||
strictMin = b.PositionalIndex + 1
|
||||
}
|
||||
}
|
||||
var argsValidator cobra.PositionalArgs = cobra.NoArgs
|
||||
switch {
|
||||
case totalMax == 0:
|
||||
argsValidator = cobra.NoArgs
|
||||
case strictMin > 0 && strictMin == totalMax:
|
||||
argsValidator = cobra.MinimumNArgs(strictMin)
|
||||
case strictMin > 0:
|
||||
argsValidator = cobra.RangeArgs(strictMin, totalMax)
|
||||
default:
|
||||
argsValidator = cobra.MaximumNArgs(totalMax)
|
||||
}
|
||||
|
||||
// Extend Use with [<placeholder>] tokens for positional bindings so
|
||||
// `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded
|
||||
// helper commands' style (e.g. devdoc article search [keyword]).
|
||||
use := route.Use
|
||||
if totalMax > 0 {
|
||||
ordered := make([]FlagBinding, 0, totalMax)
|
||||
for _, b := range route.Bindings {
|
||||
if b.Positional {
|
||||
ordered = append(ordered, b)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return ordered[i].PositionalIndex < ordered[j].PositionalIndex
|
||||
})
|
||||
var sb strings.Builder
|
||||
sb.WriteString(use)
|
||||
for _, b := range ordered {
|
||||
name := strings.TrimSpace(b.Property)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.FlagName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(" [")
|
||||
sb.WriteString(name)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
use = sb.String()
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: route.Use,
|
||||
Use: use,
|
||||
Aliases: append([]string(nil), route.Aliases...),
|
||||
Short: route.Short,
|
||||
Long: route.Long,
|
||||
Example: route.Example,
|
||||
Hidden: route.Hidden,
|
||||
Args: cobra.NoArgs,
|
||||
Args: argsValidator,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
jsonPayload, err := cmd.Flags().GetString("json")
|
||||
@@ -141,10 +234,27 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
// Inject positional args into params according to each binding's
|
||||
// PositionalIndex. Pure positional bindings are not registered as
|
||||
// flags; dual-mode positional bindings (positional + alias) only
|
||||
// fall through to positional injection when their flag aliases
|
||||
// were not used (collectPositionalBindings skips when params
|
||||
// already contains the property).
|
||||
if err := collectPositionalBindings(args, route.Bindings, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect schema-derived flags (from buildFlagsFromDetailSchema)
|
||||
// that are not covered by explicit bindings.
|
||||
collectSchemaFlags(cmd, route.Bindings, params)
|
||||
|
||||
// Required-presence check for positional bindings — must run after
|
||||
// both flag (CollectBindings) and positional (collectPositionalBindings)
|
||||
// have had a chance to populate params.
|
||||
if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if route.Normalizer != nil {
|
||||
if err := route.Normalizer(cmd, params); err != nil {
|
||||
return err
|
||||
@@ -180,6 +290,9 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
@@ -196,8 +309,64 @@ func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseFlagDefault converts a string-form envelope default into the typed
|
||||
// primitives used by pflag's *P helpers. Unparseable values silently fall
|
||||
// back to the type's zero value so a malformed envelope downgrades to
|
||||
// "no default in --help" rather than a panic at startup. The slice form
|
||||
// splits on commas and trims whitespace, mirroring pflag.StringSlice
|
||||
// behavior; empty/whitespace-only segments are dropped.
|
||||
func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
switch kind {
|
||||
case ValueString, ValueJSON:
|
||||
// Preserve raw (not trimmed) so explicitly-padded defaults survive.
|
||||
defStr = raw
|
||||
case ValueInt:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.Atoi(trimmed); err == nil {
|
||||
defInt = v
|
||||
}
|
||||
}
|
||||
case ValueFloat:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
||||
defFloat = v
|
||||
}
|
||||
}
|
||||
case ValueBool:
|
||||
if trimmed != "" {
|
||||
if v, err := strconv.ParseBool(trimmed); err == nil {
|
||||
defBool = v
|
||||
}
|
||||
}
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
if trimmed != "" {
|
||||
for _, p := range strings.Split(trimmed, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
defSlice = append(defSlice, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
for _, binding := range bindings {
|
||||
// Positional bindings are collected from cobra args rather than flags.
|
||||
// Exception: dual-mode bindings (positional + envelope-declared flag
|
||||
// aliases) also register the aliases so users can pass either
|
||||
// `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced
|
||||
// later by validateRequiredPositionalBindings instead of MarkFlagRequired.
|
||||
if binding.Positional {
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
registerPositionalAliasFlags(cmd, binding)
|
||||
continue
|
||||
}
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
if primary == "" {
|
||||
continue
|
||||
@@ -206,43 +375,77 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
if alias == primary {
|
||||
alias = ""
|
||||
}
|
||||
// Dedupe extra aliases against primary + single alias and each other.
|
||||
var extras []string
|
||||
if len(binding.Aliases) > 0 {
|
||||
seen := map[string]bool{primary: true, "json": true, "params": true}
|
||||
if alias != "" {
|
||||
seen[alias] = true
|
||||
}
|
||||
extras = make([]string, 0, len(binding.Aliases))
|
||||
for _, a := range binding.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extras = append(extras, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse binding.Default once per binding into Kind-typed values used
|
||||
// by both the primary and hidden-alias registrations below. Hidden
|
||||
// aliases share the same default so users typing the legacy alias
|
||||
// see consistent --help text and zero-value behavior.
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
registerHidden := func(name string, suffix string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
||||
case ValueInt:
|
||||
cmd.Flags().Int(name, defInt, binding.Usage+suffix)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64(name, defFloat, binding.Usage+suffix)
|
||||
case ValueBool:
|
||||
cmd.Flags().Bool(name, defBool, binding.Usage+suffix)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix)
|
||||
case ValueJSON:
|
||||
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
||||
}
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(primary, binding.Short, "", binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(primary, binding.Short, 0, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Int(alias, 0, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(primary, binding.Short, 0, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Float64(alias, 0, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(primary, binding.Short, false, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Bool(alias, false, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, nil, binding.Usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().StringSlice(alias, nil, binding.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(primary, binding.Short, "", binding.Usage+" (JSON)")
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", binding.Usage+" (alias, JSON)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
|
||||
}
|
||||
registerHidden(alias, " (alias)")
|
||||
for _, extra := range extras {
|
||||
registerHidden(extra, " (alias)")
|
||||
}
|
||||
if binding.Required {
|
||||
// When no hidden aliases exist, lean on cobra's native required
|
||||
// validation for the best UX (colored error, shown in --help).
|
||||
// When aliases exist, CollectBindings does its own "any-of-these
|
||||
// is set" check so users who type the hidden alias do not hit
|
||||
// cobra yelling about the primary being missing.
|
||||
if alias == "" && len(extras) == 0 {
|
||||
_ = cmd.MarkFlagRequired(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +455,165 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
_ = cmd.Flags().MarkHidden("params")
|
||||
}
|
||||
|
||||
// registerPositionalAliasFlags registers the visible primary flag and any
|
||||
// hidden aliases for a "dual-mode" positional binding (envelope:
|
||||
// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is
|
||||
// intentionally deferred to validateRequiredPositionalBindings — cobra's
|
||||
// MarkFlagRequired would yell even when the user supplied the value as a
|
||||
// positional arg.
|
||||
func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if alias == primary {
|
||||
alias = ""
|
||||
}
|
||||
|
||||
// Dedupe extras against primary + alias and reserved internal names.
|
||||
seen := map[string]bool{"json": true, "params": true}
|
||||
if primary != "" {
|
||||
seen[primary] = true
|
||||
}
|
||||
if alias != "" {
|
||||
seen[alias] = true
|
||||
}
|
||||
extras := make([]string, 0, len(binding.Aliases))
|
||||
for _, a := range binding.Aliases {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" || seen[a] {
|
||||
continue
|
||||
}
|
||||
seen[a] = true
|
||||
extras = append(extras, a)
|
||||
}
|
||||
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
short := ""
|
||||
if withShort {
|
||||
short = binding.Short
|
||||
}
|
||||
usage := binding.Usage + usageSuffix
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(name, short, defStr, usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(name, short, defInt, usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(name, short, defFloat, usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(name, short, defBool, usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(name, short, defSlice, usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(name, short, defStr, usage+" (JSON)")
|
||||
default:
|
||||
cmd.Flags().StringP(name, short, defStr, usage)
|
||||
}
|
||||
if hidden {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
|
||||
register(primary, true, false, "")
|
||||
register(alias, false, true, " (alias)")
|
||||
for _, e := range extras {
|
||||
register(e, false, true, " (alias)")
|
||||
}
|
||||
}
|
||||
|
||||
// collectPositionalBindings pulls positional args according to the bindings
|
||||
// and injects them into params[property]. Missing slots are skipped (cobra
|
||||
// arity validation already ran before RunE).
|
||||
func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error {
|
||||
for _, binding := range bindings {
|
||||
if !binding.Positional {
|
||||
continue
|
||||
}
|
||||
property := strings.TrimSpace(binding.Property)
|
||||
if property == "" {
|
||||
continue
|
||||
}
|
||||
// Dual-mode positional: if the user already provided the value via a
|
||||
// flag alias (CollectBindings wrote it), honor flag > positional.
|
||||
if _, ok := params[property]; ok {
|
||||
continue
|
||||
}
|
||||
if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) {
|
||||
continue
|
||||
}
|
||||
raw := args[binding.PositionalIndex]
|
||||
switch binding.Kind {
|
||||
case ValueInt:
|
||||
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
case ValueFloat:
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
case ValueBool:
|
||||
v, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property))
|
||||
}
|
||||
params[property] = v
|
||||
default:
|
||||
params[property] = raw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequiredPositionalBindings enforces required-presence for positional
|
||||
// bindings whose original envelope spec set required=true. The arity validator
|
||||
// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs
|
||||
// excluding the dual slot) so a flag-only invocation is permitted; this check
|
||||
// closes the loop by rejecting the case where neither the positional arg nor
|
||||
// any flag alias was supplied.
|
||||
func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error {
|
||||
for _, binding := range bindings {
|
||||
if !binding.Positional || !binding.Required {
|
||||
continue
|
||||
}
|
||||
property := strings.TrimSpace(binding.Property)
|
||||
if property == "" {
|
||||
continue
|
||||
}
|
||||
if v, ok := params[property]; ok {
|
||||
if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Compose candidate flag names so the error message points users at
|
||||
// the first writable label even for flag-only invocations.
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed {
|
||||
continue
|
||||
}
|
||||
display := primary
|
||||
if display == "" {
|
||||
display = alias
|
||||
}
|
||||
if display == "" && len(binding.Aliases) > 0 {
|
||||
display = binding.Aliases[0]
|
||||
}
|
||||
if display == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property))
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that
|
||||
// have no explicit FlagBinding. This bridges the gap for plugin-defined tools
|
||||
// whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags.
|
||||
@@ -265,6 +627,11 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
|
||||
if a := strings.TrimSpace(b.Alias); a != "" {
|
||||
bound[a] = true
|
||||
}
|
||||
for _, extra := range b.Aliases {
|
||||
if e := strings.TrimSpace(extra); e != "" {
|
||||
bound[e] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved/internal flags that should never be forwarded as tool params.
|
||||
@@ -316,36 +683,78 @@ func toOriginalParamName(flagName string) string {
|
||||
return strings.ReplaceAll(flagName, "-", "_")
|
||||
}
|
||||
|
||||
// firstChangedFlag returns the first name (in order) whose cobra flag has
|
||||
// been set by the user. Whitespace-only or empty entries are skipped.
|
||||
// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias >
|
||||
// extraAliases in declaration order.
|
||||
func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) {
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if cobracmd.FlagChanged(cmd, n) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) {
|
||||
if existing == nil {
|
||||
existing = map[string]any{}
|
||||
}
|
||||
params := make(map[string]any)
|
||||
for _, binding := range bindings {
|
||||
if binding.Positional {
|
||||
// Pure positional (no flag aliases) is handled by
|
||||
// collectPositionalBindings. Dual-mode positional bindings
|
||||
// (envelope: positional + alias/aliases) fall through so any
|
||||
// user-supplied flag value wins over the positional arg.
|
||||
primary := strings.TrimSpace(binding.FlagName)
|
||||
alias := strings.TrimSpace(binding.Alias)
|
||||
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
primaryName := strings.TrimSpace(binding.FlagName)
|
||||
if primaryName == "" {
|
||||
continue
|
||||
}
|
||||
aliasName := strings.TrimSpace(binding.Alias)
|
||||
primaryChanged := cobracmd.FlagChanged(cmd, primaryName)
|
||||
aliasChanged := aliasName != "" && cobracmd.FlagChanged(cmd, aliasName)
|
||||
|
||||
flagName := primaryName
|
||||
if aliasChanged {
|
||||
flagName = aliasName
|
||||
// Candidate flag names in precedence order: primary, single alias,
|
||||
// then extra aliases. Whichever is set first wins; mirrors the
|
||||
// semantics of cmdutil.FlagOrFallback.
|
||||
candidates := make([]string, 0, 2+len(binding.Aliases))
|
||||
candidates = append(candidates, primaryName)
|
||||
if aliasName != "" && aliasName != primaryName {
|
||||
candidates = append(candidates, aliasName)
|
||||
}
|
||||
for _, extra := range binding.Aliases {
|
||||
e := strings.TrimSpace(extra)
|
||||
if e == "" || e == primaryName || e == aliasName {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, e)
|
||||
}
|
||||
|
||||
flagName, anyChanged := firstChangedFlag(cmd, candidates...)
|
||||
if !anyChanged {
|
||||
flagName = primaryName
|
||||
}
|
||||
|
||||
flag := cmd.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
continue
|
||||
}
|
||||
if binding.Required && !primaryChanged && !aliasChanged {
|
||||
if binding.Required && !anyChanged && !binding.Positional {
|
||||
if _, ok := existing[binding.Property]; ok {
|
||||
continue
|
||||
}
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
|
||||
}
|
||||
if !primaryChanged && !aliasChanged {
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,71 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
|
||||
bindings := []FlagBinding{
|
||||
{
|
||||
FlagName: "query",
|
||||
Aliases: []string{"keyword"},
|
||||
Property: "keyword",
|
||||
Kind: ValueString,
|
||||
Usage: "搜索关键词",
|
||||
Required: true,
|
||||
Positional: true,
|
||||
PositionalIndex: 0,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
ApplyBindings(cmd, bindings)
|
||||
|
||||
primary := cmd.Flags().Lookup("query")
|
||||
if primary == nil {
|
||||
t.Fatal("--query flag should be registered for dual-mode positional")
|
||||
}
|
||||
if primary.Hidden {
|
||||
t.Fatal("--query flag should be visible")
|
||||
}
|
||||
hidden := cmd.Flags().Lookup("keyword")
|
||||
if hidden == nil {
|
||||
t.Fatal("--keyword alias flag should be registered")
|
||||
}
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("--keyword alias flag should be hidden")
|
||||
}
|
||||
|
||||
// --query should NOT be marked required at cobra level — that would
|
||||
// break flag-only invocation when arity is relaxed.
|
||||
if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok {
|
||||
t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Pure positional (no Alias / no Aliases) → no flag should be registered;
|
||||
// arity validator (set in NewDirectCommand) handles required-presence.
|
||||
bindings := []FlagBinding{
|
||||
{
|
||||
Property: "text",
|
||||
Kind: ValueString,
|
||||
Required: true,
|
||||
Positional: true,
|
||||
PositionalIndex: 0,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
ApplyBindings(cmd, bindings)
|
||||
|
||||
if f := cmd.Flags().Lookup("text"); f != nil {
|
||||
t.Fatalf("pure positional should not register a flag, got %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map.
|
||||
func TestKindFromTypeName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want ValueKind
|
||||
}{
|
||||
{"", ValueString},
|
||||
{"string", ValueString},
|
||||
{"STRING", ValueString},
|
||||
{"int", ValueInt},
|
||||
{"integer", ValueInt},
|
||||
{"number", ValueInt},
|
||||
{"bool", ValueBool},
|
||||
{"boolean", ValueBool},
|
||||
{"stringSlice", ValueStringSlice},
|
||||
{"string_slice", ValueStringSlice},
|
||||
{"[]string", ValueStringSlice},
|
||||
{"weird-unknown-type", ValueString},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := kindFromTypeName(tc.in); got != tc.want {
|
||||
t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOmitWhen verifies all three modes plus zero-detection.
|
||||
func TestApplyOmitWhen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("mode_zero_drops_zero_values", func(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"a": 0,
|
||||
"b": "",
|
||||
"c": false,
|
||||
"d": []any{},
|
||||
"e": map[string]any{},
|
||||
"f": "non-empty",
|
||||
"g": 42,
|
||||
}
|
||||
for k := range params {
|
||||
applyOmitWhen(params, k, "zero")
|
||||
}
|
||||
for _, k := range []string{"a", "b", "c", "d", "e"} {
|
||||
if _, ok := params[k]; ok {
|
||||
t.Errorf("expected key %q to be dropped by omitWhen=zero", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"f", "g"} {
|
||||
if _, ok := params[k]; !ok {
|
||||
t.Errorf("expected key %q to be preserved", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mode_never_preserves_zero_values", func(t *testing.T) {
|
||||
params := map[string]any{"explicitFalse": false, "explicitZero": 0}
|
||||
applyOmitWhen(params, "explicitFalse", "never")
|
||||
applyOmitWhen(params, "explicitZero", "never")
|
||||
if len(params) != 2 {
|
||||
t.Errorf("expected both keys preserved under 'never', got %v", params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mode_empty_is_noop", func(t *testing.T) {
|
||||
params := map[string]any{"a": ""}
|
||||
applyOmitWhen(params, "a", "empty")
|
||||
if _, ok := params["a"]; !ok {
|
||||
t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing_key_safe", func(t *testing.T) {
|
||||
params := map[string]any{}
|
||||
applyOmitWhen(params, "nope", "zero")
|
||||
if len(params) != 0 {
|
||||
t.Errorf("expected no-op on missing key, got %v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsZeroValue covers every branch of the helper.
|
||||
func TestIsZeroValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
zeros := []any{
|
||||
nil,
|
||||
"",
|
||||
" ",
|
||||
false,
|
||||
0,
|
||||
int64(0),
|
||||
float64(0),
|
||||
[]any{},
|
||||
[]string{},
|
||||
map[string]any{},
|
||||
}
|
||||
for i, z := range zeros {
|
||||
if !isZeroValue(z) {
|
||||
t.Errorf("case %d: expected zero value for %#v", i, z)
|
||||
}
|
||||
}
|
||||
nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}}
|
||||
for i, nz := range nonZeros {
|
||||
if isZeroValue(nz) {
|
||||
t.Errorf("case %d: expected non-zero for %#v", i, nz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve.
|
||||
func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) {
|
||||
// NOTE: not t.Parallel — edition.Get() global state is shared.
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
now := resolvers["$now"]
|
||||
if now == nil {
|
||||
t.Fatal("$now resolver missing")
|
||||
}
|
||||
if v, ok := now(context.Background()); !ok || v == "" {
|
||||
t.Errorf("$now returned empty value: %q ok=%v", v, ok)
|
||||
}
|
||||
today := resolvers["$today"]
|
||||
if today == nil {
|
||||
t.Fatal("$today resolver missing")
|
||||
}
|
||||
if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") {
|
||||
t.Errorf("$today returned unexpected value: %q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook.
|
||||
func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
defer edition.Override(prev)
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$currentUserId": func(ctx context.Context) (string, bool) {
|
||||
return "test-user-001", true
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
resolvers := runtimeDefaultResolvers()
|
||||
fn := resolvers["$currentUserId"]
|
||||
if fn == nil {
|
||||
t.Fatal("$currentUserId missing after overlay install")
|
||||
}
|
||||
if v, ok := fn(context.Background()); !ok || v != "test-user-001" {
|
||||
t.Errorf("$currentUserId=%q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,11 @@ type Service struct {
|
||||
Tenant string
|
||||
AuthIdentity string
|
||||
Logger *slog.Logger
|
||||
// PerServerTimeout overrides the default per-server discovery timeout
|
||||
// when greater than zero. Useful for tests and for callers that need a
|
||||
// tighter or looser bound. When zero, defaultPerServerDiscoveryTimeout
|
||||
// applies.
|
||||
PerServerTimeout time.Duration
|
||||
}
|
||||
|
||||
type RuntimeServer struct {
|
||||
@@ -170,7 +175,11 @@ func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.Serve
|
||||
}, nil
|
||||
}
|
||||
|
||||
const perServerDiscoveryTimeout = 5 * time.Second
|
||||
// defaultPerServerDiscoveryTimeout bounds the time spent discovering tools on
|
||||
// a single registry-listed server. Tightened to 2s so a slow/unreachable
|
||||
// server cannot stall every CLI command — a healthy MCP endpoint negotiates
|
||||
// well under a second. See issue #119.
|
||||
const defaultPerServerDiscoveryTimeout = 2 * time.Second
|
||||
|
||||
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
|
||||
type discoveryResult struct {
|
||||
@@ -178,6 +187,11 @@ func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.Serve
|
||||
failure *RuntimeFailure
|
||||
}
|
||||
|
||||
perServerTimeout := defaultPerServerDiscoveryTimeout
|
||||
if s.PerServerTimeout > 0 {
|
||||
perServerTimeout = s.PerServerTimeout
|
||||
}
|
||||
|
||||
filtered := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
if !srv.CLI.Skip {
|
||||
@@ -194,7 +208,7 @@ func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.Serve
|
||||
wg.Add(1)
|
||||
go func(server market.ServerDescriptor) {
|
||||
defer wg.Done()
|
||||
serverCtx, cancel := context.WithTimeout(ctx, perServerDiscoveryTimeout)
|
||||
serverCtx, cancel := context.WithTimeout(ctx, perServerTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
rs, err := s.DiscoverServerRuntime(serverCtx, server)
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// Category represents a stable error class with a documented exit code.
|
||||
@@ -276,7 +278,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
switch typed.ServerDiag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
|
||||
errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings"
|
||||
errorPayload["action_url"] = config.GetDeveloperSettingsURL()
|
||||
}
|
||||
}
|
||||
if typed.ServerDiag.TechnicalDetail != "" {
|
||||
@@ -342,7 +344,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
switch typed.ServerDiag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
|
||||
lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings")
|
||||
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
|
||||
}
|
||||
|
||||
if len(typed.Actions) > 0 {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// 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 errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExitCodePermission is the process exit code for PAT authorisation failures.
|
||||
const ExitCodePermission = 4
|
||||
|
||||
// PATError represents a PAT (Personal Action Token) authorization failure
|
||||
// that should be passed through to stderr as raw JSON without any CLI-layer
|
||||
// wrapping. The host application (e.g. RewindDesktop) parses the JSON to
|
||||
// display its own authorisation UI.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
func (e *PATError) Error() string { return e.RawJSON }
|
||||
|
||||
// ExitCode returns the documented exit code for PAT permission errors (4).
|
||||
func (e *PATError) ExitCode() int { return ExitCodePermission }
|
||||
|
||||
// RawStderr returns the raw JSON to be written directly to stderr.
|
||||
func (e *PATError) RawStderr() string { return e.RawJSON }
|
||||
|
||||
// patNoPermissionCodes are PAT error codes that should be passed through
|
||||
// as transparent PATError without CLI-level wrapping.
|
||||
var patNoPermissionCodes = map[string]bool{
|
||||
"PAT_NO_PERMISSION": true,
|
||||
"PAT_LOW_RISK_NO_PERMISSION": true,
|
||||
"PAT_MEDIUM_RISK_NO_PERMISSION": true,
|
||||
"PAT_HIGH_RISK_NO_PERMISSION": true,
|
||||
}
|
||||
|
||||
// patAuthRequiredCodes are error codes that trigger the PAT authorization
|
||||
// flow (e.g. the server auto-created a CLI app and returned auth details).
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
}
|
||||
|
||||
// IsPATError reports whether err is a *PATError.
|
||||
func IsPATError(err error) bool {
|
||||
_, ok := err.(*PATError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPATNoPermissionCode reports whether code is a known PAT permission error code.
|
||||
func IsPATNoPermissionCode(code string) bool {
|
||||
return patNoPermissionCodes[code]
|
||||
}
|
||||
|
||||
// ---- DWS gateway auth errors (shared between PAT & general auth) ----------
|
||||
|
||||
// dwsGatewayErrors is the set of DWS gateway-level auth error codes.
|
||||
var dwsGatewayErrors = map[string]bool{
|
||||
"DWS_SERVICE_UNAUTHORIZED": true,
|
||||
"DWS_AUTH_SERVICE_FAILED": true,
|
||||
}
|
||||
|
||||
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody
|
||||
// (supports both errorCode and error_code field names).
|
||||
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
for _, key := range []string{"errorCode", "error_code"} {
|
||||
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isNotLoggedInError checks if the error body indicates missing authentication.
|
||||
func isNotLoggedInError(body map[string]any) bool {
|
||||
if errMsg, ok := body["error"].(string); ok {
|
||||
if strings.Contains(errMsg, "Missing service_id or access_key") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isBusinessError checks if a parsed JSON body represents a business-level error.
|
||||
func isBusinessError(body map[string]any) bool {
|
||||
if _, ok := body["error"].(string); ok {
|
||||
return true
|
||||
}
|
||||
if v, ok := body["success"].(bool); ok && !v {
|
||||
return true
|
||||
}
|
||||
if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- Classification functions -----------------------------------------------
|
||||
|
||||
// ClassifyToolResultContent checks a raw MCP tool result content map for
|
||||
// DWS gateway auth errors and PAT permission error codes. This is intended
|
||||
// for use as the edition.Hooks.ClassifyToolResult callback so the framework's
|
||||
// runner returns a typed error before its generic business-error classification.
|
||||
//
|
||||
// Check order: DWS gateway auth > PAT permission.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
return NewAuth(string(raw),
|
||||
WithReason("gateway_auth_expired"),
|
||||
WithHint(authExpiredHint()),
|
||||
)
|
||||
}
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
|
||||
// Returns a typed error for known gateway auth failures, PAT interceptions,
|
||||
// and business-level errors embedded in HTTP-200 JSON bodies.
|
||||
//
|
||||
// Check order: DWS gateway > PAT permission > generic business error.
|
||||
func ClassifyMCPResponseText(text string) error {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(text), &body) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := getDWSGatewayErrorCode(body); ok {
|
||||
return NewAuth(text,
|
||||
WithReason("gateway_auth_expired"),
|
||||
WithHint(authExpiredHint()),
|
||||
)
|
||||
}
|
||||
|
||||
if isNotLoggedInError(body) {
|
||||
return NewAuth("当前未登录",
|
||||
WithReason("not_configured"),
|
||||
WithHint(notLoggedInHint()),
|
||||
WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(body, code)}
|
||||
}
|
||||
}
|
||||
|
||||
if isBusinessError(body) {
|
||||
return NewAPI(text,
|
||||
WithReason("business_error"),
|
||||
WithHint(suggestForBusinessErrorText(body)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Hints -----------------------------------------------------------------
|
||||
|
||||
func authExpiredHint() string {
|
||||
return "Re-authenticate: dws auth login"
|
||||
}
|
||||
|
||||
func notLoggedInHint() string {
|
||||
return "请先登录:dws auth login"
|
||||
}
|
||||
|
||||
func suggestForBusinessErrorText(body map[string]any) string {
|
||||
msg := ""
|
||||
if v, ok := body["errorMsg"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["message"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["error"].(string); ok {
|
||||
msg = v
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(msg, "搜索内容不能为空"):
|
||||
return "请提供非空搜索关键词: dws doc search --query \"关键词\""
|
||||
case strings.Contains(msg, "User has no permission to access this email"):
|
||||
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
|
||||
case strings.Contains(msg, "频率超限") || strings.Contains(msg, "rate limit"):
|
||||
return "API rate limit exceeded, wait a moment and retry"
|
||||
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
|
||||
return "Check input parameters. Use --help for available flags"
|
||||
default:
|
||||
return "MCP tool returned a business error; check parameters and refer to skill documentation."
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PAT JSON helpers ------------------------------------------------------
|
||||
|
||||
var patTopLevelStrip = map[string]bool{
|
||||
"success": true, "code": true, "errorCode": true, "error_code": true,
|
||||
"message": true, "error": true, "trace_id": true, "class": true,
|
||||
}
|
||||
|
||||
func cleanPATJSON(body map[string]any, code string) string {
|
||||
out := map[string]any{
|
||||
"success": false,
|
||||
"code": code,
|
||||
}
|
||||
if data, ok := body["data"]; ok {
|
||||
out["data"] = stripClassFields(data)
|
||||
} else {
|
||||
fallback := map[string]any{}
|
||||
for k, v := range body {
|
||||
if !patTopLevelStrip[k] {
|
||||
fallback[k] = v
|
||||
}
|
||||
}
|
||||
if len(fallback) > 0 {
|
||||
out["data"] = stripClassFields(fallback)
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---- Runner adapter functions ------------------------------------------------
|
||||
// These match the function signatures referenced by runner.go's PAT check
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
// ClassifyPatAuthCheck is the open-source fallback that checks a tool-call
|
||||
// Content map for PAT permission codes and auth-required codes. Returns a
|
||||
// non-nil *PATError when the content carries a recognised PAT/auth error.
|
||||
func ClassifyPatAuthCheck(content map[string]any) *PATError {
|
||||
for _, key := range []string{"code", "errorCode"} {
|
||||
if code, ok := content[key].(string); ok {
|
||||
if patNoPermissionCodes[code] || patAuthRequiredCodes[code] {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsPatAuthCheckError extracts a *PATError from an error chain.
|
||||
func AsPatAuthCheckError(err error) *PATError {
|
||||
var patErr *PATError
|
||||
if stderrors.As(err, &patErr) {
|
||||
return patErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripClassFields(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
clean := make(map[string]any, len(val))
|
||||
for k, item := range val {
|
||||
if k == "class" {
|
||||
continue
|
||||
}
|
||||
clean[k] = stripClassFields(item)
|
||||
}
|
||||
return clean
|
||||
case []any:
|
||||
clean := make([]any, len(val))
|
||||
for i, item := range val {
|
||||
clean[i] = stripClassFields(item)
|
||||
}
|
||||
return clean
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// 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 errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATError basic behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPATError_Implements(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `{"success":false,"code":"PAT_NO_PERMISSION"}`
|
||||
pe := &PATError{RawJSON: raw}
|
||||
|
||||
if pe.Error() != raw {
|
||||
t.Errorf("Error() = %q, want %q", pe.Error(), raw)
|
||||
}
|
||||
if pe.ExitCode() != ExitCodePermission {
|
||||
t.Errorf("ExitCode() = %d, want %d", pe.ExitCode(), ExitCodePermission)
|
||||
}
|
||||
if pe.RawStderr() != raw {
|
||||
t.Errorf("RawStderr() = %q, want %q", pe.RawStderr(), raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATError_True(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &PATError{RawJSON: "{}"}
|
||||
if !IsPATError(err) {
|
||||
t.Fatal("expected IsPATError to return true for *PATError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATError_False(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := stderrors.New("some other error")
|
||||
if IsPATError(err) {
|
||||
t.Fatal("expected IsPATError to return false for non-PATError")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IsPATNoPermissionCode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsPATNoPermissionCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"PAT_NO_PERMISSION", true},
|
||||
{"PAT_LOW_RISK_NO_PERMISSION", true},
|
||||
{"PAT_MEDIUM_RISK_NO_PERMISSION", true},
|
||||
{"PAT_HIGH_RISK_NO_PERMISSION", true},
|
||||
{"AGENT_CODE_NOT_EXISTS", false},
|
||||
{"UNKNOWN_CODE", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsPATNoPermissionCode(tc.code); got != tc.want {
|
||||
t.Errorf("IsPATNoPermissionCode(%q) = %v, want %v", tc.code, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDWSGatewayErrorCode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetDWSGatewayErrorCode_ErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED"}
|
||||
code, ok := getDWSGatewayErrorCode(body)
|
||||
if !ok || code != "DWS_SERVICE_UNAUTHORIZED" {
|
||||
t.Errorf("got (%q, %v), want (DWS_SERVICE_UNAUTHORIZED, true)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDWSGatewayErrorCode_ErrorCodeUnderscore(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"error_code": "DWS_AUTH_SERVICE_FAILED"}
|
||||
code, ok := getDWSGatewayErrorCode(body)
|
||||
if !ok || code != "DWS_AUTH_SERVICE_FAILED" {
|
||||
t.Errorf("got (%q, %v), want (DWS_AUTH_SERVICE_FAILED, true)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDWSGatewayErrorCode_Unknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"errorCode": "SOME_OTHER_ERROR"}
|
||||
_, ok := getDWSGatewayErrorCode(body)
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for unknown error code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDWSGatewayErrorCode_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{}
|
||||
_, ok := getDWSGatewayErrorCode(body)
|
||||
if ok {
|
||||
t.Fatal("expected ok=false for empty body")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNotLoggedInError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsNotLoggedInError_True(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"error": "Missing service_id or access_key in request headers"}
|
||||
if !isNotLoggedInError(body) {
|
||||
t.Fatal("expected true for Missing service_id message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotLoggedInError_False(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"error": "something else happened"}
|
||||
if isNotLoggedInError(body) {
|
||||
t.Fatal("expected false for unrelated error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotLoggedInError_NoErrorField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"message": "Missing service_id or access_key"}
|
||||
if isNotLoggedInError(body) {
|
||||
t.Fatal("expected false when error field is absent")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isBusinessError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsBusinessError_ErrorField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"error": "some error message"}
|
||||
if !isBusinessError(body) {
|
||||
t.Fatal("expected true when 'error' field is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBusinessError_SuccessBoolFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"success": false}
|
||||
if !isBusinessError(body) {
|
||||
t.Fatal("expected true when success=false (bool)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBusinessError_SuccessStringFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"success": "False"}
|
||||
if !isBusinessError(body) {
|
||||
t.Fatal("expected true when success=\"False\" (string)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBusinessError_SuccessTrue(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"success": true, "data": "ok"}
|
||||
if isBusinessError(body) {
|
||||
t.Fatal("expected false when success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBusinessError_EmptyBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{"data": "hello"}
|
||||
if isBusinessError(body) {
|
||||
t.Fatal("expected false for body without error indicators")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClassifyToolResultContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClassifyToolResultContent_GatewayAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED", "message": "expired"}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for gateway auth")
|
||||
}
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("expected *Error, got %T", err)
|
||||
}
|
||||
if typed.Category != CategoryAuth {
|
||||
t.Errorf("Category = %v, want %v", typed.Category, CategoryAuth)
|
||||
}
|
||||
if typed.Reason != "gateway_auth_expired" {
|
||||
t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_PATPermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{"desc": "需要授权"},
|
||||
}
|
||||
err := ClassifyToolResultContent(content)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for PAT permission")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain PAT_NO_PERMISSION, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyToolResultContent_NoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"success": true, "data": "ok"}
|
||||
if err := ClassifyToolResultContent(content); err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClassifyMCPResponseText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClassifyMCPResponseText_GatewayAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"errorCode":"DWS_SERVICE_UNAUTHORIZED","message":"token expired"}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("expected *Error, got %T", err)
|
||||
}
|
||||
if typed.Reason != "gateway_auth_expired" {
|
||||
t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_NotLoggedIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"error":"Missing service_id or access_key in headers"}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("expected *Error, got %T", err)
|
||||
}
|
||||
if typed.Reason != "not_configured" {
|
||||
t.Errorf("Reason = %q, want not_configured", typed.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATPermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"desc":"high risk"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("expected *Error, got %T", err)
|
||||
}
|
||||
if typed.Reason != "business_error" {
|
||||
t.Errorf("Reason = %q, want business_error", typed.Reason)
|
||||
}
|
||||
if !strings.Contains(typed.Hint, "搜索关键词") {
|
||||
t.Errorf("Hint should contain search suggestion, got: %s", typed.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_InvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := "not json at all"
|
||||
if err := ClassifyMCPResponseText(text); err != nil {
|
||||
t.Fatalf("expected nil for invalid JSON, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_NoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":true,"data":"hello"}`
|
||||
if err := ClassifyMCPResponseText(text); err != nil {
|
||||
t.Fatalf("expected nil for success response, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClassifyPatAuthCheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError")
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") {
|
||||
t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}}
|
||||
patErr := ClassifyPatAuthCheck(content)
|
||||
if patErr == nil {
|
||||
t.Fatal("expected non-nil *PATError for AGENT_CODE_NOT_EXISTS")
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") {
|
||||
t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_NoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"}
|
||||
if patErr := ClassifyPatAuthCheck(content); patErr != nil {
|
||||
t.Fatalf("expected nil, got %v", patErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPatAuthCheck_EmptyContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{}
|
||||
if patErr := ClassifyPatAuthCheck(content); patErr != nil {
|
||||
t.Fatalf("expected nil for empty content, got %v", patErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AsPatAuthCheckError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAsPatAuthCheckError_Wrapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||
wrapped := stderrors.Join(stderrors.New("context"), inner)
|
||||
got := AsPatAuthCheckError(wrapped)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil *PATError from wrapped error")
|
||||
}
|
||||
if got.RawJSON != inner.RawJSON {
|
||||
t.Errorf("RawJSON = %q, want %q", got.RawJSON, inner.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsPatAuthCheckError_NotPAT(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := stderrors.New("just a plain error")
|
||||
if got := AsPatAuthCheckError(err); got != nil {
|
||||
t.Fatalf("expected nil for non-PAT error, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cleanPATJSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCleanPATJSON_WithData(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "需要授权",
|
||||
"flowId": "f123",
|
||||
"class": "com.foo.Bar",
|
||||
},
|
||||
}
|
||||
result := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if !strings.Contains(result, "PAT_NO_PERMISSION") {
|
||||
t.Errorf("expected code in output, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "flowId") {
|
||||
t.Errorf("expected flowId in data, got: %s", result)
|
||||
}
|
||||
if strings.Contains(result, "class") {
|
||||
t.Errorf("expected class field to be stripped, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_WithoutData(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_NO_PERMISSION",
|
||||
"message": "no permission",
|
||||
"extra": "value",
|
||||
}
|
||||
result := cleanPATJSON(body, "PAT_NO_PERMISSION")
|
||||
if !strings.Contains(result, "extra") {
|
||||
t.Errorf("expected extra field in fallback data, got: %s", result)
|
||||
}
|
||||
// Top-level stripped fields should not appear
|
||||
if strings.Contains(result, `"message"`) {
|
||||
t.Errorf("expected message to be stripped from top level, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripClassFields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStripClassFields_Map(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"name": "test",
|
||||
"class": "com.foo.Bar",
|
||||
"nested": map[string]any{
|
||||
"value": 42,
|
||||
"class": "com.baz.Qux",
|
||||
},
|
||||
}
|
||||
result := stripClassFields(input).(map[string]any)
|
||||
if _, ok := result["class"]; ok {
|
||||
t.Error("top-level class should be removed")
|
||||
}
|
||||
nested := result["nested"].(map[string]any)
|
||||
if _, ok := nested["class"]; ok {
|
||||
t.Error("nested class should be removed")
|
||||
}
|
||||
if nested["value"] != 42 {
|
||||
t.Errorf("nested value should be preserved, got %v", nested["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClassFields_Array(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []any{
|
||||
map[string]any{"id": 1, "class": "Foo"},
|
||||
map[string]any{"id": 2},
|
||||
}
|
||||
result := stripClassFields(input).([]any)
|
||||
first := result[0].(map[string]any)
|
||||
if _, ok := first["class"]; ok {
|
||||
t.Error("class in array element should be removed")
|
||||
}
|
||||
if first["id"] != 1 {
|
||||
t.Error("other fields in array element should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClassFields_Scalar(t *testing.T) {
|
||||
t.Parallel()
|
||||
if stripClassFields("hello") != "hello" {
|
||||
t.Error("scalar string should pass through unchanged")
|
||||
}
|
||||
if stripClassFields(42) != 42 {
|
||||
t.Error("scalar int should pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// suggestForBusinessErrorText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSuggestForBusinessErrorText(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
body map[string]any
|
||||
contains string
|
||||
}{
|
||||
{map[string]any{"errorMsg": "搜索内容不能为空"}, "搜索关键词"},
|
||||
{map[string]any{"message": "User has no permission to access this email"}, "邮箱"},
|
||||
{map[string]any{"error": "频率超限"}, "rate limit"},
|
||||
{map[string]any{"errorMsg": "参数错误"}, "parameters"},
|
||||
{map[string]any{"error": "unknown"}, "business error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
hint := suggestForBusinessErrorText(tc.body)
|
||||
if !strings.Contains(strings.ToLower(hint), strings.ToLower(tc.contains)) {
|
||||
t.Errorf("suggestForBusinessErrorText(%v) = %q, want to contain %q", tc.body, hint, tc.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-1
@@ -60,6 +60,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
message.AddCommand(
|
||||
newChatMessageSendCommand(runner),
|
||||
newChatMessageSendByBotCommand(runner),
|
||||
newChatMessageRecallByBotCommand(runner),
|
||||
newChatMessageSendByWebhookCommand(runner),
|
||||
@@ -81,6 +82,123 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "以当前用户身份发送消息 (--group 群聊 / --user 或 --open-dingtalk-id 单聊)",
|
||||
Long: `以当前用户身份发送群消息或单聊消息。
|
||||
|
||||
--group 指定群聊 openConversationId 发群消息;--user 指定 userId 发单聊;
|
||||
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。`,
|
||||
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> --title "提醒" --text "请确认"
|
||||
dws chat message send --group <openconversation_id> "hello"`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params, tool, err := buildChatMessageSendInvocation(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("group", "", "群会话 openConversationId (群聊三选一)")
|
||||
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
|
||||
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
|
||||
cmd.Flags().String("title", "", "消息标题 (可选)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[string]any, string, error) {
|
||||
guard := cli.NewStdinGuard()
|
||||
|
||||
group, err := cmd.Flags().GetString("group")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --group")
|
||||
}
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --user")
|
||||
}
|
||||
openID, err := cmd.Flags().GetString("open-dingtalk-id")
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewInternal("failed to read --open-dingtalk-id")
|
||||
}
|
||||
title, err := resolveStringFlag(cmd, "title", guard, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// --text is the primary content flag: receives stdin pipe and positional
|
||||
// fallback when empty.
|
||||
text, err := resolveStringFlag(cmd, "text", guard, true)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" && len(args) > 0 {
|
||||
text = args[0]
|
||||
}
|
||||
|
||||
hasGroup := strings.TrimSpace(group) != ""
|
||||
hasUser := strings.TrimSpace(user) != ""
|
||||
hasOpenID := strings.TrimSpace(openID) != ""
|
||||
specified := 0
|
||||
if hasGroup {
|
||||
specified++
|
||||
}
|
||||
if hasUser {
|
||||
specified++
|
||||
}
|
||||
if hasOpenID {
|
||||
specified++
|
||||
}
|
||||
switch specified {
|
||||
case 0:
|
||||
return nil, "", apperrors.NewValidation("one of --group, --user, or --open-dingtalk-id is required")
|
||||
case 1:
|
||||
default:
|
||||
return nil, "", apperrors.NewValidation("--group, --user, and --open-dingtalk-id are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, "", apperrors.NewValidation("--text (or positional argument) is required")
|
||||
}
|
||||
|
||||
params := map[string]any{"text": text}
|
||||
if strings.TrimSpace(title) != "" {
|
||||
params["title"] = title
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasGroup:
|
||||
params["openConversation_id"] = group
|
||||
return params, "send_message_as_user", nil
|
||||
case hasUser:
|
||||
params["receiverUserId"] = user
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
params["receiverOpenDingTalkId"] = openID
|
||||
return params, "send_direct_message_as_user", nil
|
||||
}
|
||||
}
|
||||
|
||||
func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send-by-bot",
|
||||
@@ -95,7 +213,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
"bot",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -43,4 +44,157 @@ 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 TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantKey string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"--group", "cid-xyz", "--text", "hello"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "openConversation_id",
|
||||
wantValue: "cid-xyz",
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverUserId",
|
||||
wantValue: "034766",
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverOpenDingTalkId",
|
||||
wantValue: "OP123",
|
||||
},
|
||||
{
|
||||
name: "positional-text",
|
||||
args: []string{"--group", "cid-xyz", "hello from positional"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "text",
|
||||
wantValue: "hello from positional",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(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.Tool; got != tc.wantTool {
|
||||
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
|
||||
}
|
||||
if got := runner.last.CanonicalProduct; got != "chat" {
|
||||
t.Fatalf("CanonicalProduct = %q, want chat", got)
|
||||
}
|
||||
if got, ok := runner.last.Params[tc.wantKey]; !ok || got != tc.wantValue {
|
||||
t.Fatalf("Params[%q] = %#v, want %q", tc.wantKey, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no-destination",
|
||||
args: []string{"--text", "hi"},
|
||||
wantErr: "one of --group, --user, or --open-dingtalk-id is required",
|
||||
},
|
||||
{
|
||||
name: "group-and-user",
|
||||
args: []string{"--group", "cid-x", "--user", "034766", "--text", "hi"},
|
||||
wantErr: "--group, --user, and --open-dingtalk-id are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "empty-text",
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil; output: %s", out.String())
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, tc.wantErr) {
|
||||
t.Fatalf("error = %q, want to contain %q", got, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"管理员操作入口:": "Admin settings: ",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
@@ -170,5 +170,25 @@
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk",
|
||||
"插件管理": "Manage plugins",
|
||||
"列出已安装的插件": "List installed plugins",
|
||||
"安装插件": "Install a plugin",
|
||||
"查看插件详情": "Show plugin details",
|
||||
"启用插件": "Enable a plugin",
|
||||
"禁用插件": "Disable a plugin",
|
||||
"卸载已安装的插件": "Remove an installed plugin",
|
||||
"校验 plugin.json": "Validate a plugin.json",
|
||||
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||
"管理插件配置": "Manage plugin configuration",
|
||||
"设置插件配置项": "Set a plugin config value",
|
||||
"读取插件配置项": "Get a plugin config value",
|
||||
"列出插件所有配置项": "List all config values for a plugin",
|
||||
"删除插件配置项": "Remove a plugin config value",
|
||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
||||
"查看任意命令的帮助信息": "Help about any command",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"管理员操作入口:": "管理员操作入口:",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
@@ -170,5 +170,25 @@
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
"🔐 登录钉钉": "🔐 登录钉钉",
|
||||
"插件管理": "插件管理",
|
||||
"列出已安装的插件": "列出已安装的插件",
|
||||
"安装插件": "安装插件",
|
||||
"查看插件详情": "查看插件详情",
|
||||
"启用插件": "启用插件",
|
||||
"禁用插件": "禁用插件",
|
||||
"卸载已安装的插件": "卸载已安装的插件",
|
||||
"校验 plugin.json": "校验 plugin.json",
|
||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||
"管理插件配置": "管理插件配置",
|
||||
"设置插件配置项": "设置插件配置项",
|
||||
"读取插件配置项": "读取插件配置项",
|
||||
"列出插件所有配置项": "列出插件所有配置项",
|
||||
"删除插件配置项": "删除插件配置项",
|
||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ const (
|
||||
|
||||
// AccountToken is the account key for storing auth token data.
|
||||
AccountToken = "auth-token"
|
||||
|
||||
// StorageDirEnv overrides the on-disk keychain storage root on
|
||||
// platforms that use file-backed storage (macOS, Linux). It is
|
||||
// intended for tests that need to isolate keychain state from the
|
||||
// real user environment and from sibling test packages running in
|
||||
// parallel. When empty, the platform default applies.
|
||||
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
||||
)
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
|
||||
@@ -40,7 +40,12 @@ const (
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
// Uses ~/Library/Application Support/<service> following Apple conventions.
|
||||
// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
|
||||
// isolation), the storage root is taken from that env var instead.
|
||||
func StorageDir(service string) string {
|
||||
if override := os.Getenv(StorageDirEnv); override != "" {
|
||||
return filepath.Join(override, service)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".dws", "keychain", service)
|
||||
|
||||
@@ -34,8 +34,13 @@ const (
|
||||
)
|
||||
|
||||
// StorageDir returns the storage directory for a given service name.
|
||||
// Follows XDG Base Directory Specification: ~/.local/share/<service>
|
||||
// Follows XDG Base Directory Specification: ~/.local/share/<service>.
|
||||
// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
|
||||
// isolation), the storage root is taken from that env var instead.
|
||||
func StorageDir(service string) string {
|
||||
if override := os.Getenv(StorageDirEnv); override != "" {
|
||||
return filepath.Join(override, service)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
|
||||
+152
-8
@@ -38,6 +38,7 @@ const (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
@@ -48,6 +49,26 @@ type ListResponse struct {
|
||||
type ListMetadata struct {
|
||||
Count int `json:"count"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
// Warnings is populated by the Portal merge service when envelopes are
|
||||
// dropped (e.g. missing serverDeps, status != active) or when a dangling
|
||||
// serverDeps / toolOverrides.*.serverOverride reference is detected.
|
||||
// Old Portals without the field simply leave this nil. CLI side should
|
||||
// treat any non-empty slice as non-fatal informational output: print to
|
||||
// stderr so cache refreshes expose Portal drift to the user, but do not
|
||||
// fail the discovery load.
|
||||
//
|
||||
// See plan fix-wukong-discovery-missing-servers Phase 4.2/4.3.
|
||||
Warnings []ListWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ListWarning describes one envelope that was filtered out of the merged
|
||||
// response or flagged for dangling references. Fields mirror the JSON emitted
|
||||
// by WukongDiscoveryRegistry.buildWarning on the Portal side; unknown reason
|
||||
// codes are passed through verbatim for forward compatibility.
|
||||
type ListWarning struct {
|
||||
ProductID string `json:"productId"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type ServerEnvelope struct {
|
||||
@@ -97,6 +118,37 @@ type CLIOverlay struct {
|
||||
Tools []CLITool `json:"tools"`
|
||||
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
|
||||
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
|
||||
// ServerDeps declares other product IDs that this overlay depends on at
|
||||
// runtime (e.g. chat depends on bot for cross-server tool routing).
|
||||
// Consumed by the portal merge service for fail-fast validation; CLI side
|
||||
// currently only stores the value for tooling/introspection.
|
||||
ServerDeps []string `json:"serverDeps,omitempty"`
|
||||
// Hints registers stub sub-commands under the overlay root that only
|
||||
// print a redirect message pointing to the canonical command path. Used
|
||||
// for deprecated command aliases and "did-you-mean" style hints.
|
||||
// Key is the sub-command name; value describes the target path.
|
||||
Hints map[string]CLIHintDef `json:"hintCommands,omitempty"`
|
||||
// RedirectTo, when non-empty, turns the entire top-level product into a
|
||||
// stub that prints "Please use: dws <target>" and performs no work. Used
|
||||
// for deprecated top-level products migrated to new paths (e.g.
|
||||
// `bot → chat bot`, `message → chat message`). See schema v3 §2.6.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
// CLIHintDef declares a stub sub-command that prints a redirect message.
|
||||
// The command takes no bindings and calls no tool; its sole purpose is to
|
||||
// help users migrate from an old command path to the new one.
|
||||
type CLIHintDef struct {
|
||||
// Target is the canonical command path shown in the redirect message
|
||||
// (e.g. "dws chat message list").
|
||||
Target string `json:"target"`
|
||||
// Description overrides the Short/Long help text for the hint command.
|
||||
// Empty falls back to a generic "use: <target>" string.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Group optionally nests the hint under a named sub-group (same syntax
|
||||
// as CLIToolOverride.Group with dot-separated paths). Empty means the
|
||||
// hint is attached directly to the overlay root.
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// CLIGroupDef defines a sub-command group within a CLI module.
|
||||
@@ -104,25 +156,111 @@ type CLIGroupDef struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CLIOutputFormat declares structured post-processing applied to the MCP tool
|
||||
// response before the formatter prints it. See schema v3 §2.5.
|
||||
//
|
||||
// Apply order: Drop → Rename → Columns. All three are optional.
|
||||
type CLIOutputFormat struct {
|
||||
// Rename moves fields from src key to dst key at top level and one level
|
||||
// of nested objects. Missing src keys are silently ignored.
|
||||
Rename map[string]string `json:"rename,omitempty"`
|
||||
// Drop removes these keys from the response (top level + one level deep).
|
||||
Drop []string `json:"drop,omitempty"`
|
||||
// Columns controls column order and subset for --format=table. Ignored in
|
||||
// JSON output mode.
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
}
|
||||
|
||||
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
|
||||
type CLIToolOverride struct {
|
||||
CLIName string `json:"cliName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
|
||||
OutputFormat map[string]any `json:"outputFormat,omitempty"`
|
||||
CLIName string `json:"cliName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Example, when non-empty, is wired to cobra.Command.Example to render
|
||||
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
|
||||
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
|
||||
// produces no Examples section. Multi-line strings keep "\n" literally.
|
||||
Example string `json:"example,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
|
||||
// OutputFormat declares structured response post-processing. v3 typed form
|
||||
// supersedes v2's untyped map[string]any, but parsing stays lenient so v2
|
||||
// envelopes continue to deserialize (unknown keys are ignored).
|
||||
OutputFormat CLIOutputFormat `json:"outputFormat,omitempty"`
|
||||
// ServerOverride routes this leaf command's tool invocation to a different
|
||||
// product's MCP server (e.g. `chat bot ...` leaves live under the `chat`
|
||||
// command tree but call the `bot` endpoint). Empty means use the enclosing
|
||||
// overlay's server.
|
||||
ServerOverride string `json:"serverOverride,omitempty"`
|
||||
// BodyWrapper, when non-empty, wraps the collected params map under this
|
||||
// single key before the invocation is dispatched. Useful when the upstream
|
||||
// tool expects a typed DTO wrapper (e.g. `PersonalTodoCreateVO`). Only
|
||||
// user-provided params are wrapped; internal control keys starting with
|
||||
// '_' (e.g. `_blocked`, `_yes`) are preserved at the top level.
|
||||
BodyWrapper string `json:"bodyWrapper,omitempty"`
|
||||
// MutuallyExclusive groups flag aliases that must not be set together.
|
||||
// Each inner slice becomes one cobra.MarkFlagsMutuallyExclusive call.
|
||||
// Example: [["group","user","open-dingtalk-id"]] for `chat message list`.
|
||||
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
|
||||
// RequireOneOf groups flag aliases where at least one must be set. Each
|
||||
// inner slice becomes one cobra.MarkFlagsOneRequired call. Typically
|
||||
// paired with MutuallyExclusive to enforce "exactly one of".
|
||||
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
|
||||
// RedirectTo, when non-empty, turns this entry into a stub command that
|
||||
// prints the redirect target instead of invoking a tool. All other
|
||||
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
|
||||
// ignored. Use for deprecated leaf commands that moved to a new path.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
|
||||
type CLIFlagOverride struct {
|
||||
Alias string `json:"alias"`
|
||||
Alias string `json:"alias"`
|
||||
// Aliases registers additional hidden CLI flag names for the same MCP
|
||||
// parameter. Use to preserve legacy flag names when migrating from
|
||||
// hardcoded commands (mirrors cmdutil.ValidateRequiredFlagWithAliases /
|
||||
// cmdutil.FlagOrFallback). All entries are registered as hidden flags
|
||||
// (not shown in --help); values are deduped against the primary flag
|
||||
// name and Alias, and reserved names ("json", "params") are skipped.
|
||||
// When any alias is set by the user, the binding's Required check is
|
||||
// satisfied and the value is written to params[Property].
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Transform string `json:"transform,omitempty"`
|
||||
TransformArgs map[string]any `json:"transformArgs,omitempty"`
|
||||
EnvDefault string `json:"envDefault,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
// Shorthand registers a single-char flag alias (cobra StringP etc.).
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Required marks this flag as mandatory via cobra.MarkFlagRequired.
|
||||
// Ignored when Positional is true (positional args have their own arity rules).
|
||||
Required bool `json:"required,omitempty"`
|
||||
// Description overrides the usage string displayed in --help; takes
|
||||
// priority over the Detail API's toolDesc when non-empty.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Positional, when true, binds this parameter to a positional CLI argument
|
||||
// instead of a --flag. PositionalIndex (0-based) selects which arg slot.
|
||||
Positional bool `json:"positional,omitempty"`
|
||||
PositionalIndex int `json:"positionalIndex,omitempty"`
|
||||
// Type explicitly declares the flag's type: "string" (default) / "int" /
|
||||
// "bool" / "stringSlice". When set, overrides the type inferred from MCP
|
||||
// tools/list inputSchema. See schema v3 §2.1.
|
||||
Type string `json:"type,omitempty"`
|
||||
// OmitWhen declares empty-value handling when building the invocation body:
|
||||
//
|
||||
// "empty" (default): empty string / zero-length slice-or-map → omit
|
||||
// "zero": + zero numbers / false booleans → omit
|
||||
// "never": always send, even at zero value (explicit-zero semantics)
|
||||
//
|
||||
// See schema v3 §2.2.
|
||||
OmitWhen string `json:"omitWhen,omitempty"`
|
||||
// RuntimeDefault, when non-empty, injects a runtime-resolved value if the
|
||||
// user omits the flag. Allowed placeholders: "$currentUserId" / "$unionId"
|
||||
// / "$corpId" / "$now" / "$today". Unknown placeholders → warning + skip.
|
||||
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
|
||||
// only recognises the placeholder set. See schema v3 §2.3.
|
||||
RuntimeDefault string `json:"runtimeDefault,omitempty"`
|
||||
}
|
||||
|
||||
type CLITool struct {
|
||||
@@ -256,6 +394,9 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery("failed to create servers request")
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("servers request failed: %v", err))
|
||||
@@ -289,6 +430,9 @@ func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery("failed to create market servers request")
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ListResponse{}, apperrors.NewDiscovery(fmt.Sprintf("market servers request failed: %v", err))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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 pat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var validGrantTypes = map[string]bool{
|
||||
"once": true,
|
||||
"session": true,
|
||||
"permanent": true,
|
||||
}
|
||||
|
||||
func newChmodCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
chmodCmd := &cobra.Command{
|
||||
Use: "chmod <scope>...",
|
||||
Short: "授予指定权限",
|
||||
Long: `授予指定 scope 的操作权限。
|
||||
|
||||
scope 格式: <product>.<entity>:<permission>
|
||||
例: aitable.record:read chat.group:write calendar.event:read
|
||||
|
||||
grantType 规则:
|
||||
once 一次性,执行一次后自动失效
|
||||
session 当前会话有效(默认),需要 --session-id
|
||||
permanent 永久有效`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Example: ` dws pat chmod aitable.record:read --agentCode agt-xxxx --grant-type session --session-id session-xxx
|
||||
dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx
|
||||
dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
agentCode, _ := cmd.Flags().GetString("agentCode")
|
||||
if agentCode == "" {
|
||||
return fmt.Errorf("flag --agentCode is required\n hint: dws pat chmod <scope>... --agentCode <id>")
|
||||
}
|
||||
scopes := args
|
||||
grantType, _ := cmd.Flags().GetString("grant-type")
|
||||
sessionID, _ := cmd.Flags().GetString("session-id")
|
||||
|
||||
if !validGrantTypes[grantType] {
|
||||
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
|
||||
}
|
||||
|
||||
if grantType == "session" && sessionID == "" && os.Getenv("DWS_SESSION_ID") == "" {
|
||||
return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod <scope> --agentCode <id> --grant-type session --session-id <id>")
|
||||
}
|
||||
|
||||
if caller != nil && caller.DryRun() {
|
||||
bold := color.New(color.FgYellow, color.Bold)
|
||||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||||
fmt.Printf("%-16s%s\n", "Tool:", "个人授权")
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
|
||||
fmt.Printf("%-16s%v\n", "Scope:", scopes)
|
||||
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
|
||||
if sessionID != "" {
|
||||
fmt.Printf("%-16s%s\n", "SessionID:", sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if caller == nil {
|
||||
return fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"agentCode": agentCode,
|
||||
"scope": scopes,
|
||||
"grantType": grantType,
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv("DWS_SESSION_ID")
|
||||
}
|
||||
if sessionID != "" {
|
||||
toolArgs["sessionId"] = sessionID
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod failed: %w", err)
|
||||
}
|
||||
|
||||
return handleToolResult(result)
|
||||
},
|
||||
}
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)")
|
||||
_ = chmodCmd.MarkFlagRequired("agentCode")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
|
||||
return chmodCmd
|
||||
}
|
||||
|
||||
// handleToolResult processes a ToolResult and writes output to stdout.
|
||||
func handleToolResult(result *edition.ToolResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("empty tool result")
|
||||
}
|
||||
for _, c := range result.Content {
|
||||
if c.Type != "text" || c.Text == "" {
|
||||
continue
|
||||
}
|
||||
if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil {
|
||||
return respErr
|
||||
}
|
||||
fmt.Println(c.Text)
|
||||
return nil
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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 pat implements the "dws pat" command group for PAT (Personal Action
|
||||
// Token) authorization management.
|
||||
package pat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// RegisterCommands adds the pat command tree to rootCmd.
|
||||
func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
|
||||
patCmd := &cobra.Command{
|
||||
Use: "pat",
|
||||
Short: "行为授权管理",
|
||||
Long: `管理行为授权(PAT)。
|
||||
|
||||
命令结构:
|
||||
dws pat chmod <scope>... 授予指定权限`,
|
||||
RunE: cmdutil.GroupRunE,
|
||||
}
|
||||
|
||||
patCmd.AddCommand(newChmodCommand(c))
|
||||
root.AddCommand(patCmd)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+45
-99
@@ -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
|
||||
}
|
||||
|
||||
@@ -386,16 +330,10 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
|
||||
return nil, fmt.Errorf("plugin validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Determine install path based on workspace.
|
||||
var destDir string
|
||||
var isManaged bool
|
||||
if workspace == config.OfficialPluginWorkspace {
|
||||
destDir = filepath.Join(l.PluginsDir, config.PluginManagedDir, manifest.Name)
|
||||
isManaged = true
|
||||
} else {
|
||||
destDir = filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
|
||||
isManaged = false
|
||||
}
|
||||
// All plugins install to the user directory with workspace nesting:
|
||||
// ~/.dws/plugins/user/{workspace}/{name}/. There is no privileged
|
||||
// workspace — every plugin is third-party.
|
||||
destDir := filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
|
||||
|
||||
// Remove .git directory before copying.
|
||||
_ = os.RemoveAll(filepath.Join(cloneDir, ".git"))
|
||||
@@ -413,15 +351,12 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if !isManaged {
|
||||
qualifiedName := workspace + "/" + manifest.Name
|
||||
l.setPluginEnabled(qualifiedName, true)
|
||||
}
|
||||
qualifiedName := workspace + "/" + manifest.Name
|
||||
l.setPluginEnabled(qualifiedName, true)
|
||||
|
||||
return &Plugin{
|
||||
Manifest: *manifest,
|
||||
Root: destDir,
|
||||
IsManaged: isManaged,
|
||||
Manifest: *manifest,
|
||||
Root: destDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -471,14 +406,8 @@ func parseGitURL(gitURL string) (workspace, repoName string, err error) {
|
||||
return segments[len(segments)-2], segments[len(segments)-1], nil
|
||||
}
|
||||
|
||||
// RemovePlugin removes a user plugin. Returns an error if it's managed.
|
||||
// RemovePlugin removes an installed plugin by name.
|
||||
func (l *Loader) RemovePlugin(name string, keepData bool) error {
|
||||
// Check managed first — official plugins cannot be removed.
|
||||
managedDir := filepath.Join(l.PluginsDir, "managed", name)
|
||||
if _, err := os.Stat(managedDir); err == nil {
|
||||
return fmt.Errorf("%s is a managed plugin (DingTalk-Real-AI/%s) and cannot be removed.\n To disable it, run: dws plugin disable %s", name, name, name)
|
||||
}
|
||||
|
||||
pluginDir := l.findUserPluginDir(name)
|
||||
if pluginDir == "" {
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
@@ -489,22 +418,39 @@ func (l *Loader) RemovePlugin(name string, keepData bool) error {
|
||||
}
|
||||
|
||||
if !keepData {
|
||||
dataDir := filepath.Join(l.PluginsDir, "data", name)
|
||||
dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name)
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
l.setPluginEnabled(name, false)
|
||||
l.purgePluginFromSettings(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgePluginFromSettings removes all traces of a plugin from settings.json:
|
||||
// its enabled flag and any persisted pluginConfigs entry. Called after
|
||||
// RemovePlugin succeeds so settings.json does not retain dangling state for
|
||||
// plugins that no longer exist on disk.
|
||||
func (l *Loader) purgePluginFromSettings(name string) {
|
||||
settings := l.loadSettings()
|
||||
changed := false
|
||||
if _, ok := settings.EnabledPlugins[name]; ok {
|
||||
delete(settings.EnabledPlugins, name)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := settings.PluginConfigs[name]; ok {
|
||||
delete(settings.PluginConfigs, name)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
l.saveSettings(settings)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -678,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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -200,8 +199,7 @@ func TestPluginToServerDescriptors(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Root: "/tmp/plugins/conference",
|
||||
IsManaged: true,
|
||||
Root: "/tmp/plugins/conference",
|
||||
}
|
||||
|
||||
descriptors := p.ToServerDescriptors()
|
||||
@@ -218,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)
|
||||
@@ -274,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,70 +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) {
|
||||
// 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.
|
||||
func TestRemovePluginPurgesSettings(t *testing.T) {
|
||||
const pkgName = "my-plugin"
|
||||
dir := t.TempDir()
|
||||
managedDir := filepath.Join(dir, "managed", "conference")
|
||||
if err := os.MkdirAll(managedDir, 0o755); err != nil {
|
||||
pluginDir := filepath.Join(dir, "user", pkgName)
|
||||
if err := os.MkdirAll(pluginDir, 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 {
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
|
||||
[]byte(`{"name":"`+pkgName+`","version":"1.0.0"}`), 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))
|
||||
// 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"},
|
||||
},
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
loader.saveSettings(settings)
|
||||
|
||||
func TestRemoveManagedPluginBlocked(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)
|
||||
if err := loader.RemovePlugin(pkgName, false); err != nil {
|
||||
t.Fatalf("RemovePlugin: %v", err)
|
||||
}
|
||||
|
||||
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
|
||||
err := loader.RemovePlugin("conference", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when removing managed plugin")
|
||||
reloaded := loader.loadSettings()
|
||||
if _, exists := reloaded.EnabledPlugins[pkgName]; exists {
|
||||
t.Errorf("EnabledPlugins should not retain removed plugin %q", pkgName)
|
||||
}
|
||||
if !contains(err.Error(), "managed plugin") {
|
||||
t.Errorf("error message should mention managed plugin, got: %v", err)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,32 +481,6 @@ func TestParseGitURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"empty = yes", "\n", true},
|
||||
{"y = yes", "y\n", true},
|
||||
{"Y = yes", "Y\n", true},
|
||||
{"yes = yes", "yes\n", true},
|
||||
{"n = no", "n\n", false},
|
||||
{"no = no", "no\n", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
r := strings.NewReader(tt.input)
|
||||
got := promptUpdate(&buf, r, "test-plugin", "1.0.0", "2.0.0", "")
|
||||
if got != tt.want {
|
||||
t.Errorf("promptUpdate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevPluginRegistration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
// 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 plugin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// pluginDownloadEndpoint is the API endpoint for downloading plugin updates.
|
||||
const pluginDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||||
|
||||
// lastCheckFileName stores the last update check timestamp.
|
||||
const lastCheckFileName = ".last-update-check"
|
||||
|
||||
// pluginDownloadTimeout is the timeout for plugin download operations.
|
||||
const pluginDownloadTimeout = 5 * time.Minute
|
||||
|
||||
// Updater checks and applies updates for managed plugins.
|
||||
type Updater struct {
|
||||
PluginsDir string
|
||||
CLIVersion string
|
||||
Platform string // e.g. "darwin-arm64", "linux-amd64"
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewUpdater creates an Updater with auto-detected platform.
|
||||
func NewUpdater(pluginsDir, cliVersion string) *Updater {
|
||||
return &Updater{
|
||||
PluginsDir: pluginsDir,
|
||||
CLIVersion: cliVersion,
|
||||
Platform: runtime.GOOS + "-" + runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
// remoteVersionInfo holds version metadata returned by the download API.
|
||||
type remoteVersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
Changelog string `json:"changelog,omitempty"`
|
||||
}
|
||||
|
||||
// pluginDownloadResponse represents the API response from the plugin
|
||||
// download endpoint.
|
||||
type pluginDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result *remoteVersionInfo `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAndUpdate checks for updates for all managed plugins.
|
||||
// It reads a last-check timestamp file to avoid checking too frequently.
|
||||
// Returns the list of updated plugin names.
|
||||
func (u *Updater) CheckAndUpdate(ctx context.Context, accessToken string, w io.Writer) []string {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if !u.shouldCheck() {
|
||||
slog.Debug("plugin: skipping update check (checked recently)")
|
||||
return nil
|
||||
}
|
||||
|
||||
managedDir := filepath.Join(u.PluginsDir, config.PluginManagedDir)
|
||||
entries, err := os.ReadDir(managedDir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
slog.Warn("plugin: cannot read managed dir for update check",
|
||||
"path", managedDir, "error", err)
|
||||
}
|
||||
u.recordCheckTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
var updated []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginDir := filepath.Join(managedDir, entry.Name())
|
||||
pluginName := config.OfficialPluginWorkspace + "/" + entry.Name()
|
||||
|
||||
result := u.checkAndUpdateOne(ctx, accessToken, pluginDir, pluginName, w)
|
||||
if result != "" {
|
||||
updated = append(updated, result)
|
||||
}
|
||||
}
|
||||
|
||||
u.recordCheckTime()
|
||||
return updated
|
||||
}
|
||||
|
||||
// EnsureManaged checks that every plugin in config.DefaultManagedPlugins
|
||||
// exists locally under ~/.dws/plugins/managed/. Missing plugins are
|
||||
// downloaded from the remote API and extracted automatically.
|
||||
// This runs once on first launch (or after a user deletes the managed dir).
|
||||
func (u *Updater) EnsureManaged(ctx context.Context, accessToken string, w io.Writer) []string {
|
||||
if len(config.DefaultManagedPlugins) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
managedDir := filepath.Join(u.PluginsDir, config.PluginManagedDir)
|
||||
|
||||
var installed []string
|
||||
for _, shortName := range config.DefaultManagedPlugins {
|
||||
pluginDir := filepath.Join(managedDir, shortName)
|
||||
|
||||
// Already exists locally — skip.
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
qualifiedName := config.OfficialPluginWorkspace + "/" + shortName
|
||||
fmt.Fprintf(w, "📦 Pulling built-in plugin %s ...\n", qualifiedName)
|
||||
|
||||
remote, err := u.checkRemoteVersion(ctx, accessToken, qualifiedName)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: failed to fetch remote info for default plugin",
|
||||
"plugin", qualifiedName, "error", err)
|
||||
fmt.Fprintf(w, " ⚠️ Failed to fetch %s info: %v\n", qualifiedName, err)
|
||||
continue
|
||||
}
|
||||
if remote == nil || remote.DownloadURL == "" {
|
||||
slog.Warn("plugin: no download URL for default plugin",
|
||||
"plugin", qualifiedName)
|
||||
fmt.Fprintf(w, " ⚠️ No version available for %s\n", qualifiedName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := u.downloadAndInstall(ctx, remote.DownloadURL, pluginDir); err != nil {
|
||||
slog.Warn("plugin: failed to install default plugin",
|
||||
"plugin", qualifiedName, "error", err)
|
||||
fmt.Fprintf(w, " ❌ Failed to install %s: %v\n", qualifiedName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, " ✅ Installed %s (%s)\n", qualifiedName, remote.Version)
|
||||
installed = append(installed, qualifiedName)
|
||||
}
|
||||
|
||||
return installed
|
||||
}
|
||||
|
||||
// checkAndUpdateOne checks and potentially updates a single managed plugin.
|
||||
func (u *Updater) checkAndUpdateOne(
|
||||
ctx context.Context,
|
||||
accessToken, pluginDir, pluginName string,
|
||||
w io.Writer,
|
||||
) string {
|
||||
manifest, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
slog.Warn("plugin: cannot parse manifest for update check",
|
||||
"plugin", pluginName, "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
remote, err := u.checkRemoteVersion(ctx, accessToken, pluginName)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: failed to check remote version",
|
||||
"plugin", pluginName, "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if remote == nil || remote.Version == "" || remote.DownloadURL == "" {
|
||||
slog.Debug("plugin: no remote version info available",
|
||||
"plugin", pluginName)
|
||||
return ""
|
||||
}
|
||||
|
||||
if compareSemver(remote.Version, manifest.Version) <= 0 {
|
||||
slog.Debug("plugin: already up to date",
|
||||
"plugin", pluginName,
|
||||
"local", manifest.Version,
|
||||
"remote", remote.Version)
|
||||
return ""
|
||||
}
|
||||
|
||||
if !promptUpdate(w, os.Stdin, pluginName, manifest.Version, remote.Version, remote.Changelog) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := u.downloadAndInstall(ctx, remote.DownloadURL, pluginDir); err != nil {
|
||||
slog.Warn("plugin: failed to download and install update",
|
||||
"plugin", pluginName, "error", err)
|
||||
fmt.Fprintf(w, " Update failed: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, " ✅ Updated %s to %s\n", pluginName, remote.Version)
|
||||
return pluginName
|
||||
}
|
||||
|
||||
// shouldCheck returns true if enough time has elapsed since the last check.
|
||||
func (u *Updater) shouldCheck() bool {
|
||||
checkFile := filepath.Join(u.PluginsDir, lastCheckFileName)
|
||||
data, err := os.ReadFile(checkFile)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
lastCheck, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return time.Since(lastCheck) >= config.PluginUpdateCheckInterval
|
||||
}
|
||||
|
||||
// recordCheckTime writes the current time to the last-check file.
|
||||
func (u *Updater) recordCheckTime() {
|
||||
checkFile := filepath.Join(u.PluginsDir, lastCheckFileName)
|
||||
_ = os.MkdirAll(filepath.Dir(checkFile), config.DirPerm)
|
||||
_ = os.WriteFile(checkFile, []byte(time.Now().Format(time.RFC3339)), config.FilePerm)
|
||||
}
|
||||
|
||||
// checkRemoteVersion queries the aihub API for the latest version.
|
||||
func (u *Updater) checkRemoteVersion(ctx context.Context, accessToken, pluginName string) (*remoteVersionInfo, error) {
|
||||
apiURL := fmt.Sprintf("%s?pluginName=%s&platform=%s",
|
||||
pluginDownloadEndpoint,
|
||||
url.QueryEscape(pluginName),
|
||||
url.QueryEscape(u.Platform),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: config.HTTPTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check remote version: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var result pluginDownloadResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
errMsg := result.ErrorMsg
|
||||
if errMsg == "" {
|
||||
errMsg = result.ErrorCode
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error"
|
||||
}
|
||||
return nil, fmt.Errorf("API error: %s", errMsg)
|
||||
}
|
||||
|
||||
return result.Result, nil
|
||||
}
|
||||
|
||||
// downloadAndInstall downloads a plugin zip and extracts it, replacing
|
||||
// the previous version.
|
||||
func (u *Updater) downloadAndInstall(ctx context.Context, downloadURL, pluginDir string) error {
|
||||
tempFile, err := os.CreateTemp("", "dws-plugin-update-*.zip")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
tempFile.Close()
|
||||
return fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: pluginDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tempFile.Close()
|
||||
return fmt.Errorf("download plugin: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tempFile.Close()
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||
tempFile.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
tempFile.Close()
|
||||
|
||||
// Remove old plugin directory contents before extracting.
|
||||
if err := os.RemoveAll(pluginDir); err != nil {
|
||||
return fmt.Errorf("remove old plugin: %w", err)
|
||||
}
|
||||
|
||||
if err := extractPluginZip(tempPath, pluginDir); err != nil {
|
||||
return fmt.Errorf("extract plugin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPluginZip extracts a zip archive to the destination directory
|
||||
// with zip slip protection.
|
||||
func extractPluginZip(zipPath, destDir string) error {
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
cleanDest := filepath.Clean(destDir) + string(os.PathSeparator)
|
||||
|
||||
for _, file := range reader.File {
|
||||
filePath := filepath.Join(destDir, file.Name)
|
||||
|
||||
if !strings.HasPrefix(filepath.Clean(filePath), cleanDest) {
|
||||
return fmt.Errorf("invalid file path in zip: %s", file.Name)
|
||||
}
|
||||
|
||||
// Reject symlinks in ZIP to prevent path traversal attacks.
|
||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks are not allowed in plugin zip: %s", file.Name)
|
||||
}
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(filePath, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create parent directory: %w", err)
|
||||
}
|
||||
|
||||
if err := extractOneFile(file, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractOneFile extracts one file from a zip archive to disk.
|
||||
func extractOneFile(file *zip.File, destPath string) error {
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file in zip: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
fileMode := file.Mode()
|
||||
if fileMode&0o600 == 0 {
|
||||
fileMode = 0o644
|
||||
}
|
||||
|
||||
destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
||||
return fmt.Errorf("extract file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptUpdate asks the user for confirmation before applying an update.
|
||||
// Returns true if the user accepts (Y or empty input means yes).
|
||||
func promptUpdate(w io.Writer, r io.Reader, pluginName, oldVer, newVer, changelog string) bool {
|
||||
fmt.Fprintf(w, "🔄 %s %s → %s", pluginName, oldVer, newVer)
|
||||
if changelog != "" {
|
||||
fmt.Fprintf(w, "\n %s", changelog)
|
||||
}
|
||||
fmt.Fprintf(w, "\n Update? [Y/n] ")
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
if !scanner.Scan() {
|
||||
return false // EOF or error: non-interactive, skip
|
||||
}
|
||||
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// 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 plugin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// makePluginZip creates an in-memory zip containing a valid plugin.json.
|
||||
func makePluginZip(t *testing.T, name, version string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
|
||||
manifest := map[string]any{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"mcpServers": map[string]any{
|
||||
name: map[string]any{
|
||||
"type": "streamable-http",
|
||||
"endpoint": "https://example.com/" + name,
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(manifest)
|
||||
|
||||
f, err := w.Create("plugin.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestEnsureManaged_PullsMissing(t *testing.T) {
|
||||
pluginName := "conference"
|
||||
zipData := makePluginZip(t, pluginName, "1.0.0")
|
||||
|
||||
// Serve the zip file.
|
||||
zipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Write(zipData)
|
||||
}))
|
||||
defer zipServer.Close()
|
||||
|
||||
// Serve the download API returning the zip URL.
|
||||
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := pluginDownloadResponse{
|
||||
Success: true,
|
||||
Result: &remoteVersionInfo{
|
||||
Version: "1.0.0",
|
||||
DownloadURL: zipServer.URL + "/conference.zip",
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer apiServer.Close()
|
||||
|
||||
// Override the download endpoint for this test.
|
||||
origEndpoint := pluginDownloadEndpoint
|
||||
defer func() {
|
||||
// pluginDownloadEndpoint is a const, so we use a workaround:
|
||||
// we won't restore it — instead we accept the const limitation
|
||||
// and test via a helper that injects the endpoint.
|
||||
_ = origEndpoint
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
u := &Updater{
|
||||
PluginsDir: tmpDir,
|
||||
CLIVersion: "1.0.0",
|
||||
Platform: "darwin-arm64",
|
||||
}
|
||||
|
||||
// Patch checkRemoteVersion by using a custom updater method —
|
||||
// since checkRemoteVersion uses the const endpoint, we test
|
||||
// downloadAndInstall + EnsureManaged logic directly.
|
||||
managedDir := filepath.Join(tmpDir, config.PluginManagedDir)
|
||||
|
||||
// Verify plugin does not exist yet.
|
||||
pluginDir := filepath.Join(managedDir, pluginName)
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err == nil {
|
||||
t.Fatal("plugin should not exist before EnsureManaged")
|
||||
}
|
||||
|
||||
// Simulate what EnsureManaged does: downloadAndInstall for missing plugin.
|
||||
if err := os.MkdirAll(managedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := u.downloadAndInstall(context.Background(), zipServer.URL+"/conference.zip", pluginDir)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAndInstall: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin.json was extracted.
|
||||
m, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest after install: %v", err)
|
||||
}
|
||||
if m.Name != pluginName {
|
||||
t.Errorf("name = %q, want %q", m.Name, pluginName)
|
||||
}
|
||||
if m.Version != "1.0.0" {
|
||||
t.Errorf("version = %q, want 1.0.0", m.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureManaged_SkipsExisting(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
managedDir := filepath.Join(tmpDir, config.PluginManagedDir)
|
||||
|
||||
// Pre-create the plugin directory with a valid manifest.
|
||||
pluginDir := filepath.Join(managedDir, "conference")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"conference","version":"1.0.0","mcpServers":{"conference":{"type":"streamable-http","endpoint":"https://example.com"}}}`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u := &Updater{
|
||||
PluginsDir: tmpDir,
|
||||
CLIVersion: "1.0.0",
|
||||
Platform: "darwin-arm64",
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
// EnsureManaged should not attempt any download (no token needed since it skips).
|
||||
installed := u.EnsureManaged(context.Background(), "fake-token", &output)
|
||||
|
||||
if len(installed) != 0 {
|
||||
t.Errorf("expected 0 installs for existing plugin, got %d: %v", len(installed), installed)
|
||||
}
|
||||
// Should produce no output since nothing was downloaded.
|
||||
if strings.Contains(output.String(), "Pulling") {
|
||||
t.Errorf("unexpected download attempt for existing plugin: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPluginZip_ZipSlipProtection(t *testing.T) {
|
||||
// Create a zip with a path traversal entry.
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
f, _ := w.Create("../../etc/passwd")
|
||||
f.Write([]byte("malicious"))
|
||||
w.Close()
|
||||
|
||||
tmpZip := filepath.Join(t.TempDir(), "bad.zip")
|
||||
os.WriteFile(tmpZip, buf.Bytes(), 0o644)
|
||||
|
||||
destDir := filepath.Join(t.TempDir(), "dest")
|
||||
err := extractPluginZip(tmpZip, destDir)
|
||||
if err == nil {
|
||||
t.Fatal("expected zip slip error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid file path") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
@@ -298,6 +298,7 @@ func SupportedProtocolVersions() []string {
|
||||
}
|
||||
|
||||
func (c *Client) Initialize(ctx context.Context, endpoint string) (InitializeResult, error) {
|
||||
var lastErr error
|
||||
for _, version := range SupportedProtocolVersions() {
|
||||
params := map[string]any{
|
||||
"capabilities": map[string]any{},
|
||||
@@ -309,18 +310,33 @@ func (c *Client) Initialize(ctx context.Context, endpoint string) (InitializeRes
|
||||
}
|
||||
|
||||
var payload InitializeResult
|
||||
if err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
|
||||
err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "initialize",
|
||||
Params: params,
|
||||
}, true, &payload); err == nil {
|
||||
}, true, &payload)
|
||||
if err == nil {
|
||||
if payload.ProtocolVersion == "" {
|
||||
payload.ProtocolVersion = version
|
||||
}
|
||||
payload.RequestedProtocolVersion = version
|
||||
return payload, nil
|
||||
}
|
||||
lastErr = err
|
||||
// Only protocol-level JSON-RPC errors justify trying another version.
|
||||
// Transport/HTTP failures (dial timeout, connection refused, HTTP 5xx,
|
||||
// etc.) fail identically regardless of protocol version, so looping
|
||||
// over three versions only multiplies the dial cost — e.g. on an
|
||||
// unreachable endpoint, three 3-second dials amount to 9 seconds
|
||||
// before the outer context surrenders.
|
||||
var callErr *CallError
|
||||
if !errors.As(err, &callErr) || callErr.Stage != CallStageJSONRPC {
|
||||
return InitializeResult{}, err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return InitializeResult{}, lastErr
|
||||
}
|
||||
return InitializeResult{}, apperrors.NewDiscovery(fmt.Sprintf("initialize failed for all supported protocol versions at %s", RedactURL(endpoint)))
|
||||
}
|
||||
@@ -509,6 +525,15 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: log identity-related headers on first attempt.
|
||||
if attempt == 0 && c.FileLogger != nil {
|
||||
c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers",
|
||||
slog.String("endpoint", endpoint),
|
||||
slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
|
||||
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
|
||||
)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -71,6 +72,72 @@ func TestInitializeNegotiatesProtocolVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeShortCircuitsOnHTTPError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Track which protocol versions actually get sent. With the short-circuit
|
||||
// in place, a transport-layer (HTTP) failure should fail Initialize on the
|
||||
// FIRST version without iterating through every supported version. Without
|
||||
// the short-circuit, three round-trips would happen — needlessly tripling
|
||||
// every CLI startup when a plugin endpoint is broken (issue #119).
|
||||
var seenVersions []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
params := req["params"].(map[string]any)
|
||||
seenVersions = append(seenVersions, params["protocolVersion"].(string))
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.MaxRetries = 0 // skip the HTTP retry loop — we only care about version iteration
|
||||
|
||||
if _, err := client.Initialize(context.Background(), server.URL); err == nil {
|
||||
t.Fatal("Initialize() error = nil, want HTTP error")
|
||||
}
|
||||
|
||||
if len(seenVersions) != 1 {
|
||||
t.Fatalf("Initialize() attempted %d protocol versions (%v), want 1 — HTTP failures must short-circuit",
|
||||
len(seenVersions), seenVersions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeShortCircuitsOnDialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Bind to an ephemeral port, then close the listener. Subsequent connects
|
||||
// to that address fail with "connection refused" almost instantly. With
|
||||
// the short-circuit, three protocol versions would otherwise stack three
|
||||
// dial-error returns; we only want one — and we want Initialize to return
|
||||
// well under the per-dial budget.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
endpoint := "http://" + ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
client := NewClient(nil)
|
||||
client.MaxRetries = 0
|
||||
|
||||
start := time.Now()
|
||||
if _, err := client.Initialize(context.Background(), endpoint); err == nil {
|
||||
t.Fatal("Initialize() error = nil, want dial failure")
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Three dial attempts (one per supported version) on a refused-connection
|
||||
// path is still fast on loopback, so this assertion is a sanity bound, not
|
||||
// the primary signal — but if the short-circuit regresses, on a real
|
||||
// unreachable address this jumps from one dial timeout to three.
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("Initialize() took %v, want <2s — dial failure should short-circuit", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListToolsRetriesOnServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package authretry exposes the AuthRefreshRequired marker error used by
|
||||
// edition overlays (in separate Go modules) to signal "the current access
|
||||
// token was rejected by the server; please run a force-refresh and retry".
|
||||
//
|
||||
// The package has no dependencies beyond the standard library so it can be
|
||||
// imported by both the runner (internal/app) and external overlays without
|
||||
// creating import cycles with packages such as pkg/runtimetoken.
|
||||
package authretry
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
)
|
||||
|
||||
// AuthRefreshRequired is a marker error returned by overlays (typically from
|
||||
// the edition's ClassifyToolResult or OnAuthError hook) to ask the runner to
|
||||
// perform a one-shot ForceRefresh of the access token and retry the current
|
||||
// invocation.
|
||||
//
|
||||
// Open-source code paths never produce this type, so the runner's retry
|
||||
// branch is a no-op for editions that don't opt in.
|
||||
type AuthRefreshRequired struct {
|
||||
// Cause is the underlying user-facing error. The runner returns Cause
|
||||
// (not the wrapper) when refresh fails or the retry budget is exhausted,
|
||||
// so end users always see the original diagnostic instead of an internal
|
||||
// "auth refresh required" message.
|
||||
Cause error
|
||||
}
|
||||
|
||||
// Error returns the underlying cause's message, or a generic fallback when
|
||||
// Cause is nil.
|
||||
func (e *AuthRefreshRequired) Error() string {
|
||||
if e == nil || e.Cause == nil {
|
||||
return "authentication refresh required"
|
||||
}
|
||||
return e.Cause.Error()
|
||||
}
|
||||
|
||||
// Unwrap exposes the underlying cause to errors.Is / errors.As.
|
||||
func (e *AuthRefreshRequired) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// As walks the error chain and returns the first *AuthRefreshRequired it
|
||||
// finds, along with true. Returns nil, false otherwise.
|
||||
//
|
||||
// Use this instead of a plain errors.As when callers care about acting on
|
||||
// the marker (force-refresh + retry) rather than just identifying the
|
||||
// underlying error class.
|
||||
func As(err error) (*AuthRefreshRequired, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
var target *AuthRefreshRequired
|
||||
if stderrors.As(err, &target) {
|
||||
return target, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when
|
||||
// the same-named path does not already exist. Groups recurse. On leaf
|
||||
// conflicts the dynamic side wins by default, because the discovery envelope
|
||||
// is the runtime authority — hardcoded commands are retained only as a
|
||||
// leaf-level fallback for behaviour the envelope explicitly does not
|
||||
// declare. See _docs/discovery-overlay-authority.md.
|
||||
//
|
||||
// Explicit opt-in override: a hardcoded leaf may promote itself over a
|
||||
// same-named dynamic leaf by carrying a strictly higher OverridePriority
|
||||
// (see SetOverridePriority / OverridePriority). This exists for the narrow
|
||||
// case where the envelope exposes one dispatch path but the hardcoded leaf
|
||||
// needs richer flag-based routing (e.g. `chat message send` fanning out to
|
||||
// multiple MCP tools depending on --group vs --user), which the envelope
|
||||
// cannot currently express. Helpers without the annotation still lose to
|
||||
// the envelope, so the default authority contract is preserved.
|
||||
//
|
||||
// PRECONDITION: dynamicRoot must be envelope-sourced (carry the
|
||||
// SourceAnnotation=SourceEnvelope marker set by BuildDynamicCommands via
|
||||
// MarkEnvelopeSource). Callers that might otherwise pass a helper-fallback
|
||||
// root with the same name are responsible for evicting it upstream —
|
||||
// otherwise the "envelope is authority" rule silently promotes helper leaves
|
||||
// over same-named hardcoded leaves and the overlay loses its ability to
|
||||
// override routing. The wukong overlay's RegisterProducts gates this call on
|
||||
// IsEnvelopeSourced(dynamicRoot); new callers must do the same.
|
||||
//
|
||||
// Conflict resolution table:
|
||||
//
|
||||
// dynamic hardcoded → action
|
||||
// ------- --------------------------------- -----------------------------
|
||||
// absent anything graft hardcoded subtree
|
||||
// leaf leaf (hc priority ≤ dyn) dynamic wins (no-op)
|
||||
// leaf leaf (hc priority > dyn) hardcoded replaces dynamic
|
||||
// group group recurse
|
||||
// leaf group dynamic wins, warn
|
||||
// group leaf dynamic wins, warn
|
||||
//
|
||||
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it so callers
|
||||
// can chain. hardcodedRoot is treated as a donor: grafted children are
|
||||
// detached from it so their cobra parent pointer points at the new parent.
|
||||
func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command {
|
||||
if dynamicRoot == nil || hardcodedRoot == nil {
|
||||
return dynamicRoot
|
||||
}
|
||||
// Snapshot children before mutating hardcodedRoot — RemoveCommand during
|
||||
// iteration over hardcodedRoot.Commands() is unsafe because cobra returns
|
||||
// a slice backed by an internal field that is re-sliced on removal.
|
||||
children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...)
|
||||
for _, hc := range children {
|
||||
dyn := findChildByName(dynamicRoot, hc.Name())
|
||||
switch {
|
||||
case dyn == nil:
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
case IsLeafCmd(hc) && IsLeafCmd(dyn):
|
||||
if OverridePriority(hc) > OverridePriority(dyn) {
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
}
|
||||
// else: envelope is authority; hardcoded leaf is ignored.
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
default:
|
||||
slog.Warn("overlay: shape mismatch, keeping dynamic",
|
||||
"name", hc.Name(),
|
||||
"dynamicIsLeaf", IsLeafCmd(dyn),
|
||||
"hardcodedIsLeaf", IsLeafCmd(hc))
|
||||
}
|
||||
}
|
||||
return dynamicRoot
|
||||
}
|
||||
|
||||
// IsLeafCmd reports whether a command has no subcommands. Leaves carry a RunE
|
||||
// and are invocation targets; groups merely organise subcommands.
|
||||
func IsLeafCmd(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return !cmd.HasSubCommands()
|
||||
}
|
||||
|
||||
// findChildByName scans parent's direct children for a matching Name(). A
|
||||
// local helper so pkg/cmdutil stays independent of internal/cobracmd.
|
||||
func findChildByName(parent *cobra.Command, name string) *cobra.Command {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
for _, child := range parent.Commands() {
|
||||
if child.Name() == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newGroup returns a group command with the given children attached.
|
||||
func newGroup(name string, children ...*cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: name}
|
||||
cmd.AddCommand(children...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newLeaf returns a leaf command tagged via Short so tests can verify which
|
||||
// variant (dynamic or hardcoded) wins without introducing a priority system.
|
||||
func newLeaf(name, tag string) *cobra.Command {
|
||||
return &cobra.Command{Use: name, Short: tag}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_NilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := MergeHardcodedLeaves(nil, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
dyn := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(dyn, nil); got != dyn {
|
||||
t.Fatal("expected dyn to be returned unchanged")
|
||||
}
|
||||
hc := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(nil, hc); got != nil {
|
||||
t.Fatal("expected nil when dynamicRoot is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_GraftsUnknownLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("kept", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("extra", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "extra")
|
||||
if got == nil {
|
||||
t.Fatal("expected extra leaf to be grafted onto dyn")
|
||||
}
|
||||
if got.Short != "hardcoded" {
|
||||
t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded")
|
||||
}
|
||||
if findChildByName(hc, "extra") != nil {
|
||||
t.Fatal("expected extra to be detached from hardcodedRoot")
|
||||
}
|
||||
if got.Parent() != dyn {
|
||||
t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_DynamicLeafWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("shared", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("shared", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got == nil {
|
||||
t.Fatal("expected shared leaf to remain on dyn")
|
||||
}
|
||||
if got.Short != "dynamic" {
|
||||
t.Fatalf("shared.Short = %q, want %q (dynamic must win)", got.Short, "dynamic")
|
||||
}
|
||||
if findChildByName(hc, "shared") == nil {
|
||||
t.Fatal("expected hardcoded.shared to remain on hardcodedRoot (not grafted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_HigherPriorityHardcodedOverridesDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got == nil {
|
||||
t.Fatal("expected shared leaf on dyn after merge")
|
||||
}
|
||||
if got != hcLeaf {
|
||||
t.Fatalf("expected hardcoded leaf to replace dynamic; got Short=%q", got.Short)
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded.shared to be moved off hardcodedRoot after replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_EqualPriorityKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities must keep dynamic; got Short=%q", got.Short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_RecurseGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "dynamic"),
|
||||
),
|
||||
)
|
||||
hc := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "hardcoded"), // dynamic wins
|
||||
newLeaf("create", "hardcoded"), // grafted
|
||||
),
|
||||
newLeaf("ping", "hardcoded"), // grafted top-level
|
||||
)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
space := findChildByName(dyn, "space")
|
||||
if space == nil {
|
||||
t.Fatal("expected space group on dyn")
|
||||
}
|
||||
if l := findChildByName(space, "list"); l == nil || l.Short != "dynamic" {
|
||||
t.Fatalf("space.list should remain dynamic, got %+v", l)
|
||||
}
|
||||
if c := findChildByName(space, "create"); c == nil || c.Short != "hardcoded" {
|
||||
t.Fatalf("space.create should be grafted from hardcoded, got %+v", c)
|
||||
}
|
||||
if p := findChildByName(dyn, "ping"); p == nil || p.Short != "hardcoded" {
|
||||
t.Fatalf("ping should be grafted from hardcoded, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeaves_ShapeMismatch_KeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Dynamic declares `cmd` as a group; hardcoded declares `cmd` as a leaf.
|
||||
// Shape mismatch — dynamic wins, warning logged, hardcoded leaf discarded.
|
||||
dyn := newGroup("root",
|
||||
newGroup("cmd", newLeaf("sub", "dynamic")),
|
||||
)
|
||||
hc := newGroup("root", newLeaf("cmd", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
cmd := findChildByName(dyn, "cmd")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected cmd to remain on dyn")
|
||||
}
|
||||
if IsLeafCmd(cmd) {
|
||||
t.Fatal("expected dynamic cmd to remain a group")
|
||||
}
|
||||
if findChildByName(cmd, "sub") == nil {
|
||||
t.Fatal("expected cmd.sub to remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeafCmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
leaf := newLeaf("x", "")
|
||||
group := newGroup("x", newLeaf("child", ""))
|
||||
if !IsLeafCmd(leaf) {
|
||||
t.Fatal("expected leaf to be a leaf")
|
||||
}
|
||||
if IsLeafCmd(group) {
|
||||
t.Fatal("expected group to not be a leaf")
|
||||
}
|
||||
if IsLeafCmd(nil) {
|
||||
t.Fatal("expected nil to not be a leaf")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// OverridePriorityAnnotation is the cobra.Command.Annotations key used to
|
||||
// declare a command's merge-time override priority. Higher values win when
|
||||
// same-named leaves collide during merge. Exported so overlays and helpers
|
||||
// can reference the same key as the core merge logic without spelling drift.
|
||||
const OverridePriorityAnnotation = "dws.override-priority"
|
||||
|
||||
// SetOverridePriority stamps the override priority annotation on cmd. A
|
||||
// positive value asks the merge layer to promote this command over a
|
||||
// same-named leaf with a lower (or unset) priority.
|
||||
func SetOverridePriority(cmd *cobra.Command, priority int) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[OverridePriorityAnnotation] = strconv.Itoa(priority)
|
||||
}
|
||||
|
||||
// OverridePriority returns the override priority annotation value on cmd,
|
||||
// or 0 if the annotation is absent or malformed.
|
||||
func OverridePriority(cmd *cobra.Command) int {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cmd.Annotations[OverridePriorityAnnotation])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// SourceAnnotation is the cobra.Command.Annotations key used to record where
|
||||
// a top-level command came from. Edition overlays (e.g. wukong) read this
|
||||
// annotation to distinguish envelope-authored dynamic commands from
|
||||
// helper-fallback commands that merely happen to share a name. Keeping the
|
||||
// key and value literals in one place prevents spelling drift between the
|
||||
// core (which sets the annotation) and overlays (which read it).
|
||||
const SourceAnnotation = "dws.source"
|
||||
|
||||
// SourceEnvelope marks a command as authored by the discovery envelope and
|
||||
// therefore authoritative at runtime. Only commands built from a
|
||||
// market.ServerDescriptor / CLIOverlay should carry this value. Helper
|
||||
// fallbacks and other sources must leave the annotation unset.
|
||||
const SourceEnvelope = "envelope"
|
||||
|
||||
// MarkEnvelopeSource stamps the envelope provenance annotation on cmd.
|
||||
// Safe to call on commands that may not have an Annotations map yet.
|
||||
// Callers in core code are the only ones that should invoke this — overlays
|
||||
// read the annotation but must not fabricate envelope provenance.
|
||||
func MarkEnvelopeSource(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[SourceAnnotation] = SourceEnvelope
|
||||
}
|
||||
|
||||
// IsEnvelopeSourced reports whether cmd carries the envelope provenance
|
||||
// annotation. Commands without the annotation are treated as non-authoritative
|
||||
// (helper fallbacks, overlay-injected stubs, etc.).
|
||||
func IsEnvelopeSourced(cmd *cobra.Command) bool {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
return cmd.Annotations[SourceAnnotation] == SourceEnvelope
|
||||
}
|
||||
+49
-19
@@ -18,6 +18,8 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -110,32 +112,60 @@ const (
|
||||
// ── Plugin system ──────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// PluginManagedDir is the subdirectory under ~/.dws/plugins/ for
|
||||
// official (DingTalk-Real-AI) plugins that are auto-pulled.
|
||||
PluginManagedDir = "managed"
|
||||
|
||||
// PluginUserDir is the subdirectory under ~/.dws/plugins/ for
|
||||
// user-installed third-party plugins.
|
||||
// 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.
|
||||
PluginUserDir = "user"
|
||||
|
||||
// PluginDataDir is the subdirectory under ~/.dws/plugins/ for
|
||||
// plugin persistent data that survives across version updates.
|
||||
PluginDataDir = "data"
|
||||
|
||||
// PluginUpdateCheckInterval is how often to check for official
|
||||
// plugin updates (at most once per interval per CLI invocation).
|
||||
PluginUpdateCheckInterval = 1 * time.Hour
|
||||
|
||||
// PluginHookTimeout is the default timeout for plugin hook commands.
|
||||
PluginHookTimeout = 30 * time.Second
|
||||
|
||||
// OfficialPluginWorkspace is the workspace name that identifies
|
||||
// official plugins. Plugins under this workspace are auto-pulled.
|
||||
OfficialPluginWorkspace = "DingTalk-Real-AI"
|
||||
)
|
||||
|
||||
// DefaultManagedPlugins lists the official plugins that should be
|
||||
// automatically pulled on first run if not already present locally.
|
||||
// Each entry is the short plugin name (without the workspace prefix);
|
||||
// the full qualified name is OfficialPluginWorkspace + "/" + name.
|
||||
var DefaultManagedPlugins = []string{}
|
||||
// ── Platform URLs ────────────────────────────────────────────────────────────
|
||||
// Shared across auth, errors, and device-flow packages.
|
||||
|
||||
const (
|
||||
// DefaultTerminalBaseURL is the DingTalk developer platform base URL.
|
||||
// Override at runtime via ~/.dws/terminal_url file.
|
||||
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
|
||||
|
||||
// DeveloperSettingsPath is the path to the organization developer
|
||||
// settings page (CLI access management).
|
||||
DeveloperSettingsPath = "/fe/old#/developerSettings"
|
||||
)
|
||||
|
||||
// DefaultConfigDir returns the default DWS configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func DefaultConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".dws"
|
||||
}
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
func GetTerminalBaseURL() string {
|
||||
terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url")
|
||||
if data, err := os.ReadFile(terminalURLPath); err == nil {
|
||||
if u := strings.TrimSpace(string(data)); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return DefaultTerminalBaseURL
|
||||
}
|
||||
|
||||
// GetDeveloperSettingsURL returns the full URL to the organization developer
|
||||
// settings page, derived from the terminal base URL.
|
||||
func GetDeveloperSettingsURL() string {
|
||||
return GetTerminalBaseURL() + DeveloperSettingsPath
|
||||
}
|
||||
|
||||
@@ -19,5 +19,12 @@ package edition
|
||||
func defaultHooks() *Hooks {
|
||||
return &Hooks{
|
||||
Name: "open",
|
||||
MergeHeaders: func(base map[string]string) map[string]string {
|
||||
if base == nil {
|
||||
base = make(map[string]string)
|
||||
}
|
||||
base["claw-type"] = "openClaw"
|
||||
return base
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+50
-12
@@ -55,6 +55,12 @@ type ToolCaller interface {
|
||||
DryRun() bool
|
||||
}
|
||||
|
||||
// RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g.
|
||||
// "$currentUserId") to a concrete string value. Called lazily at RunE time.
|
||||
// Returning (_, false) is equivalent to "not registered" and falls through
|
||||
// to the next-lower default source.
|
||||
type RuntimeDefaultFn func(ctx context.Context) (string, bool)
|
||||
|
||||
// Hooks groups all edition-specific behavioural overrides. Zero values
|
||||
// fall back to open-source defaults so the struct is safe to use as-is.
|
||||
type Hooks struct {
|
||||
@@ -73,26 +79,49 @@ type Hooks struct {
|
||||
// --- HTTP headers ---
|
||||
MergeHeaders func(base map[string]string) map[string]string
|
||||
|
||||
// --- auth hooks ---
|
||||
OnAuthError func(configDir string, err error) error
|
||||
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
|
||||
// --- auth ---
|
||||
AuthClientID string // OAuth client ID for device-flow authorisation
|
||||
AuthClientFromMCP bool // true → fetch client ID from MCP at runtime
|
||||
OnAuthError func(configDir string, err error) error
|
||||
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
|
||||
|
||||
// --- token persistence (overlay-only) ---
|
||||
// When non-nil, these override the default keychain-based token storage.
|
||||
// The data parameter is JSON-serialized TokenData.
|
||||
SaveToken func(configDir string, data []byte) error
|
||||
LoadToken func(configDir string) ([]byte, error)
|
||||
DeleteToken func(configDir string) error
|
||||
// --- token persistence (overlay-managed keychain / encrypted storage) ---
|
||||
SaveToken func(configDir string, data []byte) error // persist token blob
|
||||
LoadToken func(configDir string) ([]byte, error) // retrieve token blob
|
||||
DeleteToken func(configDir string) error // remove persisted token
|
||||
|
||||
// --- auth credentials (overlay-only) ---
|
||||
AuthClientID string // non-empty overrides DefaultClientID
|
||||
AuthClientFromMCP bool // true routes OAuth through MCP endpoints
|
||||
// --- MCP result classification ---
|
||||
// ClassifyToolResult inspects raw MCP tool-call content and returns a typed
|
||||
// error (e.g. PATError, CLIError) when the response contains a known
|
||||
// gateway-auth or PAT-permission failure. nil → no special handling.
|
||||
// ClassifyToolResult func(content map[string]any) error
|
||||
|
||||
// --- product & endpoint ---
|
||||
StaticServers func() []ServerInfo // non-nil → skip Market discovery
|
||||
VisibleProducts func() []string // non-nil → override help visibility
|
||||
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
|
||||
|
||||
// --- discovery ---
|
||||
|
||||
// DiscoveryURL overrides the Market API endpoint for server list.
|
||||
// Non-empty → loadDynamicCommands uses FetchServersFromURL(DiscoveryURL)
|
||||
// instead of the default Market base URL. Provides edition-level isolation.
|
||||
DiscoveryURL string
|
||||
|
||||
// DiscoveryHeaders returns HTTP headers injected into discovery requests.
|
||||
// Used to authenticate edition-specific endpoints.
|
||||
DiscoveryHeaders func() map[string]string
|
||||
|
||||
// SupplementServers returns edition-specific MCP servers NOT registered
|
||||
// in any Market registry. Always merged into the endpoint map alongside
|
||||
// Market/cache results, regardless of discovery success or failure.
|
||||
SupplementServers func() []ServerInfo
|
||||
|
||||
// FallbackServers returns the full server list as a safety net when
|
||||
// Market discovery + cache both fail. Results are NOT cached so the
|
||||
// next startup still attempts live discovery.
|
||||
FallbackServers func() []ServerInfo
|
||||
|
||||
// AfterPersistentPreRun runs at the end of the root PersistentPreRunE after
|
||||
// global setup (OAuth flag overrides, log level, output sink). Overlays use
|
||||
// this for clients that bypass the MCP runner (e.g. A2A gateway).
|
||||
@@ -104,6 +133,15 @@ type Hooks struct {
|
||||
// this to return custom error types with specific exit codes (e.g. PAT
|
||||
// authorization errors with exit code 4).
|
||||
ClassifyToolResult func(content map[string]any) error
|
||||
|
||||
// --- schema v3: runtime defaults ---
|
||||
|
||||
// RuntimeDefaults returns resolvers for runtimeDefault placeholders (e.g.
|
||||
// "$currentUserId" → fn). Placeholders not in the map fall through to a
|
||||
// "not registered" warning. Open-source core returns an empty map;
|
||||
// overlays populate the whitelist ($currentUserId / $unionId / $corpId /
|
||||
// $now / $today). See schema v3 §2.3.
|
||||
RuntimeDefaults func() map[string]RuntimeDefaultFn
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package editiontest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -85,4 +86,47 @@ func RunContractTests(t *testing.T, h *edition.Hooks) {
|
||||
t.Error("VisibleProducts() must return at least one product when set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SupplementServers_valid_when_set", func(t *testing.T) {
|
||||
if h.SupplementServers == nil {
|
||||
return
|
||||
}
|
||||
servers := h.SupplementServers()
|
||||
if len(servers) == 0 {
|
||||
t.Error("SupplementServers() must return at least one server when set")
|
||||
}
|
||||
for i, s := range servers {
|
||||
if s.ID == "" || s.Endpoint == "" {
|
||||
t.Errorf("SupplementServers()[%d]: ID and Endpoint must be non-empty", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FallbackServers_superset_of_supplement", func(t *testing.T) {
|
||||
if h.FallbackServers == nil || h.SupplementServers == nil {
|
||||
return
|
||||
}
|
||||
fallback := h.FallbackServers()
|
||||
if len(fallback) == 0 {
|
||||
t.Error("FallbackServers() must return at least one server when set")
|
||||
}
|
||||
fbIDs := make(map[string]bool, len(fallback))
|
||||
for _, s := range fallback {
|
||||
fbIDs[s.ID] = true
|
||||
}
|
||||
for _, s := range h.SupplementServers() {
|
||||
if !fbIDs[s.ID] {
|
||||
t.Errorf("SupplementServers ID %q not found in FallbackServers", s.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DiscoveryURL_valid_when_set", func(t *testing.T) {
|
||||
if h.DiscoveryURL == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(h.DiscoveryURL, "https://") {
|
||||
t.Errorf("DiscoveryURL must start with https://, got %q", h.DiscoveryURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+58
-16
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)时使用。
|
||||
cli_version: ">=1.0.6"
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
|
||||
cli_version: ">=1.0.15"
|
||||
---
|
||||
|
||||
# 钉钉全产品 Skill
|
||||
@@ -24,35 +24,43 @@ cli_version: ">=1.0.6"
|
||||
|
||||
| 产品 | 用途 | 参考文件 |
|
||||
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `aitable` | AI表格:表格/数据表/字段/记录增删改查/模板搜索 | [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) |
|
||||
| `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) |
|
||||
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
|
||||
用户提到"表格/多维表/AI表格/记录/数据" → `aitable`
|
||||
用户提到"审批/请假/报销/出差/加班" → `oa`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
用户提到"日程/日历/会议室/约会" → `calendar`
|
||||
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
|
||||
用户提到"群聊/建群/群成员/群管理/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
|
||||
用户提到"通讯录/同事/部门/组织架构" → `contact`
|
||||
用户提到"开发/API/调用错误 文档" → `devdoc`
|
||||
用户提到"DING/紧急消息/电话提醒" → `ding`
|
||||
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
|
||||
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
|
||||
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
|
||||
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
|
||||
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `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)
|
||||
@@ -64,12 +72,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` | 移除群成员 |
|
||||
| `chat` | `message recall-by-bot` | 撤回机器人已发消息 |
|
||||
| `doc` | `block delete` | 删除文档块(不可恢复) |
|
||||
| `ding` | `message recall` | 撤回已发 DING 消息 |
|
||||
| `oa` | `approval revoke` | 撤销自己发起的审批实例 |
|
||||
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
|
||||
| `todo` | `task delete` | 删除待办 |
|
||||
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
|
||||
|
||||
### 确认流程
|
||||
```
|
||||
@@ -86,6 +104,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 闭环
|
||||
@@ -96,7 +138,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表格字段类型规则
|
||||
|
||||
@@ -31,9 +31,9 @@ Flags:
|
||||
|
||||
> 📎 **操作后返回文档链接**:遍历返回的每个 base,拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
|
||||
|
||||
> ⚠️ **注意**:`base list` 仅返回**最近访问过**的 Base,不是全部 Base。
|
||||
> 新创建的 Base 如果尚未在钉钉前端打开过,可能不会出现在此列表中。
|
||||
> 如需查找特定 Base,请使用 `base search`;如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
|
||||
> ⚠️ **重要**:`base list` 仅返回**最近访问过**的 Base,**不是全部 Base**。
|
||||
> 如需查找表格,**请优先使用 `base search`**;`base list` 仅作为浏览最近表格的辅助手段。
|
||||
> 如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
|
||||
|
||||
#### 搜索 AI 表格
|
||||
```
|
||||
@@ -56,6 +56,8 @@ Flags:
|
||||
--base-id string Base 唯一标识 (必填)
|
||||
```
|
||||
|
||||
> 💡 **用户提供 URL 时**:如果用户给出了链接如 `https://alidocs.dingtalk.com/i/nodes/ABC123`,请提取末尾的 `ABC123` 作为 `--base-id` 传入。详见下方「URL → baseId 提取」章节。
|
||||
|
||||
返回 baseName、tables、dashboards 的 summary 信息(不含字段与记录详情)。
|
||||
后续如需 tableId,优先从这里读取。
|
||||
|
||||
@@ -186,15 +188,24 @@ Flags:
|
||||
Usage:
|
||||
dws aitable field create [flags]
|
||||
Example:
|
||||
# 单字段模式
|
||||
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]'
|
||||
--name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}'
|
||||
|
||||
# 批量模式
|
||||
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--fields string 待新增字段 JSON 数组,至少 1 个,单次最多 15 个 (必填)
|
||||
--name string 单字段名称(与 --type 配合使用,替代 --fields)
|
||||
--type string 单字段类型(参考 table create 字段类型)
|
||||
--config string 单字段配置 JSON(可选,如 options)
|
||||
--fields string 批量新增字段 JSON 数组,单次最多 15 个(与 --name/--type 二选一)
|
||||
--table-id string Table ID (必填)
|
||||
```
|
||||
|
||||
允许部分成功,返回结果逐项标明成功/失败状态。
|
||||
`--name/--type/--config` 为单字段模式;`--fields` 为批量模式;两种模式二选一。
|
||||
|
||||
#### 更新字段
|
||||
```
|
||||
@@ -204,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。
|
||||
@@ -236,13 +248,13 @@ Usage:
|
||||
Example:
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "关键词" --limit 50
|
||||
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
|
||||
Flags:
|
||||
--base-id string Base ID (必填)
|
||||
--cursor string 分页游标,首次不传
|
||||
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
|
||||
--filters string 结构化过滤条件 JSON
|
||||
--keyword string 全文关键词搜索
|
||||
--query string 全文关键词搜索
|
||||
--limit int 单次最大记录数,默认 100,最大 100
|
||||
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
|
||||
--sort string 排序条件 JSON 数组
|
||||
@@ -251,6 +263,10 @@ Flags:
|
||||
|
||||
两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。
|
||||
|
||||
> ⚠️ **排序参数规范(关键)**:`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),**不要使用 `order`**。
|
||||
>
|
||||
> 正确示例:`--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]'`
|
||||
|
||||
filters 结构:`{"operator":"and|or","operands":[{"operator":"<op>","operands":["<fieldId>","<value>"]}]}`
|
||||
|
||||
> 💡 **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。
|
||||
@@ -347,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 (模板搜索)
|
||||
|
||||
#### 搜索模板
|
||||
@@ -365,10 +703,48 @@ Flags:
|
||||
|
||||
> 📎 **模板预览地址**:`https://docs.dingtalk.com/table/template/{templateId}`
|
||||
|
||||
## 复杂操作
|
||||
|
||||
### 仪表盘 / 图表(建议顺序)
|
||||
|
||||
```bash
|
||||
# 1) 先看配置模板(JSONC)
|
||||
dws aitable dashboard config-example --format json
|
||||
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
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
|
||||
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
|
||||
- dashboard/chart 的 create/update 只有 `--config`,没有独立的 `--name` / `--type`,名称与布局都在 JSON 里。
|
||||
|
||||
### 导入外部数据(两步走)
|
||||
|
||||
```bash
|
||||
# 1) 先上传文件拿 importId
|
||||
dws aitable import upload --base-id <BASE_ID> --file-name sales.xlsx --file-size 204800 --format json
|
||||
# 把本地文件 PUT 到返回的 uploadUrl(过期前必须上传完成)
|
||||
|
||||
# 2) 触发导入
|
||||
dws aitable import data --import-id <IMPORT_ID> --timeout 60 --format json
|
||||
```
|
||||
|
||||
要点:`--file-size` 单位字节,必须与实际文件一致;`--timeout` 单位秒,不是毫秒。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"表格/多维表/AI表格":
|
||||
- 查看/列表 → `base list`
|
||||
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
|
||||
- 搜索 → `base search`
|
||||
- 详情 → `base get`
|
||||
- 创建 → `base create`
|
||||
@@ -393,9 +769,37 @@ Flags:
|
||||
- 修改/更新 → `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=图表
|
||||
|
||||
## 核心工作流
|
||||
|
||||
@@ -423,15 +827,80 @@ 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` 筛选语法排错与使用规范(极易出错)
|
||||
|
||||
调用 `record query` 时,如果条件筛选**完全失效(查询返回了所有记录)**,通常是因为 `--filters` JSON 语法错误,API 默默丢弃了不合规的 filter。
|
||||
|
||||
**强制规则:**
|
||||
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符。
|
||||
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中。
|
||||
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值,而不是 ID。
|
||||
4. **内层比较操作符语义**:支持 `eq`(等于)、`not_eq`(不等于)、`contain`(包含/模糊搜索)、`not_contain`(不包含)、`gt/gte`(大于/大于等于)、`lt/lte`(小于/小于等于)、`is_empty/is_not_empty`(为空/不为空,对应 operands 内只传单个 fieldId)。
|
||||
|
||||
**精简防呆模板与 4 种衍生情况**
|
||||
```json
|
||||
{
|
||||
"operator": "and", // 情况 4 (OR 查询): 这里改为 "or"
|
||||
"operands": [
|
||||
{
|
||||
"operator": "eq", // 情况 3 (文本包含): 这里改为 "contain"
|
||||
"operands": ["fld_state", "进行中"] // 情况 1 (基础等于)
|
||||
}
|
||||
// 情况 2 (多条件 AND): 在此行增加类似 {"operator":"eq","operands":["fld_priority","高"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**错误示例 1:缺失根节点 and/or**(API 将忽略该 filter,返回全表)
|
||||
```json
|
||||
{"operator":"eq","operands":["fldXXX","本科"]}
|
||||
```
|
||||
|
||||
**错误示例 2:传入选项 ID 而非名称**(可能导致匹配不到 0 记录)
|
||||
```json
|
||||
{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]}
|
||||
```
|
||||
|
||||
## URL → baseId 提取
|
||||
|
||||
用户经常通过钉钉链接指定表格,链接格式为:
|
||||
|
||||
```
|
||||
https://alidocs.dingtalk.com/i/nodes/{baseId}
|
||||
https://alidocs.dingtalk.com/i/nodes/{baseId}?xxx=yyy
|
||||
```
|
||||
|
||||
**处理规则**(必须严格遵守):
|
||||
1. 当用户提供了包含 `alidocs.dingtalk.com/i/nodes/` 的 URL 时,提取 `/nodes/` 后的路径段作为 `baseId`
|
||||
2. 去掉尾部的查询参数(`?` 及其后内容)和尾部斜杠
|
||||
3. 将提取得到的 ID 传入 `--base-id` 参数
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户输入:帮我查看 https://alidocs.dingtalk.com/i/nodes/ABC123XYZ 这个表格
|
||||
→ 提取 baseId = ABC123XYZ
|
||||
→ 执行: dws aitable base get --base-id ABC123XYZ --format json
|
||||
```
|
||||
|
||||
> 💡 **注意**:URL 中的 nodeId 在 AI 表格场景下等同于 baseId,可以直接作为 `--base-id` 使用。
|
||||
|
||||
### cells 写入/读取格式速查
|
||||
|
||||
@@ -450,3 +919,7 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
| group | `[{"cid":"xxx"}]` (注意: key 是 cid,不是 openConversationId) | 同写入 |
|
||||
|
||||
- 详见 [field-rules.md](../field-rules.md) 和 [error-codes.md](../error-codes.md)
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [doc](./doc.md) — 富文本文档编辑,不是结构化数据表格
|
||||
|
||||
@@ -30,11 +30,17 @@ Usage:
|
||||
Example:
|
||||
dws calendar event create --title "Q1 复盘会" \
|
||||
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
|
||||
dws calendar event create --title "Q1 复盘会" \
|
||||
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
|
||||
--attendees userId1,userId2 --timezone "Asia/Shanghai" --desc "Q1 复盘"
|
||||
Flags:
|
||||
--desc string 日程描述
|
||||
--end string 结束时间 ISO-8601 (必填)
|
||||
--start string 开始时间 ISO-8601 (必填)
|
||||
--title string 日程标题 (必填)
|
||||
--attendees string 参会人 userId 列表 (逗号分隔)
|
||||
--desc string 日程描述
|
||||
--end string 结束时间 ISO-8601 (必填)
|
||||
--open-dingtalk-ids string 参会人 openDingTalkId 列表 (逗号分隔)
|
||||
--start string 开始时间 ISO-8601 (必填)
|
||||
--timezone string 时区 (如 Asia/Shanghai)
|
||||
--title string 日程标题 (必填)
|
||||
```
|
||||
|
||||
### 修改日程
|
||||
@@ -43,11 +49,14 @@ Usage:
|
||||
dws calendar event update [flags]
|
||||
Example:
|
||||
dws calendar event update --id <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
@@ -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> --title "问候" "你好" --format json
|
||||
|
||||
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "问候" "你好" --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 图片/文件消息
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -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 只查**官方开发者文档**
|
||||
@@ -0,0 +1,577 @@
|
||||
# 文档 (doc) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 搜索文档
|
||||
```
|
||||
Usage:
|
||||
dws doc search [flags]
|
||||
Example:
|
||||
dws doc search --query "会议纪要"
|
||||
dws doc search
|
||||
dws doc search --extensions pdf,docx
|
||||
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
|
||||
dws doc search --creator-uids uid1,uid2
|
||||
dws doc search --workspace-ids wsId1,wsId2
|
||||
Flags:
|
||||
--query string 搜索关键词 (不传则返回最近访问)
|
||||
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。支持的在线文档类型后缀名: adoc=文字, axls=表格, appt=演示文稿, awbd=白板, adraw=画板, amind=脑图, able=多维表格, aform=收集表
|
||||
--created-from int 创建时间起始 (毫秒时间戳,含)
|
||||
--created-to int 创建时间截止 (毫秒时间戳,含)
|
||||
--visited-from int 访问时间起始 (毫秒时间戳,含)
|
||||
--visited-to int 访问时间截止 (毫秒时间戳,含)
|
||||
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
|
||||
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
|
||||
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
|
||||
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
|
||||
--page-size int 每页数量 (默认 10,最大 30)
|
||||
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
|
||||
```
|
||||
|
||||
### 遍历文件列表
|
||||
```
|
||||
Usage:
|
||||
dws doc list [flags]
|
||||
Example:
|
||||
dws doc list
|
||||
dws doc list --folder <FOLDER_ID>
|
||||
dws doc list --workspace <WS_ID> --page-size 20
|
||||
Flags:
|
||||
--folder string 文件夹 ID 或 URL
|
||||
--workspace string 知识库 ID
|
||||
--page-size int 每页数量 (默认 50,最大 50)
|
||||
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
|
||||
```
|
||||
|
||||
### 获取文档元信息
|
||||
```
|
||||
Usage:
|
||||
dws doc info [flags]
|
||||
Example:
|
||||
dws doc info --node <DOC_ID>
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
### 读取文档内容
|
||||
```
|
||||
Usage:
|
||||
dws doc read [flags]
|
||||
Example:
|
||||
dws doc read --node <DOC_ID>
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
### 创建文档
|
||||
```
|
||||
Usage:
|
||||
dws doc create [flags]
|
||||
Example:
|
||||
dws doc create --name "项目周报"
|
||||
dws doc create --name "Q1 总结" --markdown "# Q1 总结" --folder <FOLDER_ID>
|
||||
dws doc create --name "知识库文档" --workspace <WS_ID>
|
||||
Flags:
|
||||
--name string 文档名称 (必填)
|
||||
--folder string 目标文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID
|
||||
--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:
|
||||
dws doc update [flags]
|
||||
Example:
|
||||
dws doc update --node <DOC_ID> --markdown "# 追加内容" --mode append
|
||||
dws doc update --node <DOC_ID> --markdown "# 完整替换" --mode overwrite
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--markdown string Markdown 内容 (必填)
|
||||
--mode string 更新模式: overwrite=覆盖, append=追加 (默认 append)
|
||||
```
|
||||
|
||||
### 上传文件到钉钉文档或钉钉知识库
|
||||
```
|
||||
Usage:
|
||||
dws doc upload [flags]
|
||||
Example:
|
||||
dws doc upload --file ./report.pdf
|
||||
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
|
||||
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
|
||||
Flags:
|
||||
--file string 本地文件路径 (必填)
|
||||
--name string 文件显示名称 (默认使用文件名)
|
||||
--folder string 目标文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID
|
||||
--convert 是否转换为钉钉在线文档
|
||||
```
|
||||
|
||||
### 下载文件到本地
|
||||
```
|
||||
Usage:
|
||||
dws doc download [flags]
|
||||
Example:
|
||||
dws doc download --node <NODE_ID>
|
||||
dws doc download --node <NODE_ID> --output ./report.pdf
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ~/downloads/
|
||||
Flags:
|
||||
--node string 文件节点 ID 或 URL (必填)
|
||||
--output string 本地保存路径 (文件路径或目录,必填)
|
||||
```
|
||||
|
||||
### 创建文件夹
|
||||
```
|
||||
Usage:
|
||||
dws doc folder create [flags]
|
||||
Example:
|
||||
dws doc folder create --name "项目资料"
|
||||
dws doc folder create --name "子文件夹" --folder <PARENT_FOLDER_ID>
|
||||
Flags:
|
||||
--name string 文件夹名称 (必填)
|
||||
--folder string 父文件夹 ID 或 URL
|
||||
--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:
|
||||
dws doc block list [flags]
|
||||
Example:
|
||||
dws doc block list --node <DOC_ID>
|
||||
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
|
||||
dws doc block list --node <DOC_ID> --block-type heading
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--start-index int 起始位置 (从 0 开始)
|
||||
--end-index int 终止位置 (含)
|
||||
--block-type string 按块类型过滤
|
||||
```
|
||||
|
||||
### 插入块元素
|
||||
```
|
||||
Usage:
|
||||
dws doc block insert [flags]
|
||||
Example:
|
||||
dws doc block insert --node <DOC_ID> --text "这是一段文字"
|
||||
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
|
||||
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
|
||||
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--text string 快捷: 段落文本内容
|
||||
--heading string 快捷: 标题文本
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级)
|
||||
--index int 参照位置索引 (从 0 开始)
|
||||
--where string 插入方向: before / after (默认 after)
|
||||
--ref-block string 参照块 ID (优先级高于 --index)
|
||||
```
|
||||
|
||||
### 更新块元素
|
||||
```
|
||||
Usage:
|
||||
dws doc block update [flags]
|
||||
Example:
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--block-id string 目标块 ID (必填)
|
||||
--text string 快捷: 段落文本内容
|
||||
--heading string 快捷: 标题文本
|
||||
--level int 标题级别 1-6 (配合 --heading,默认 1)
|
||||
--element string 块元素 JSON (高级)
|
||||
```
|
||||
|
||||
### 删除块元素
|
||||
|
||||
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws doc block delete [flags]
|
||||
Example:
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
--block-id string 目标块 ID (必填)
|
||||
```
|
||||
|
||||
### 查询文档评论列表
|
||||
```
|
||||
Usage:
|
||||
dws doc comment list [flags]
|
||||
Example:
|
||||
dws doc comment list --node <DOC_ID>
|
||||
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
|
||||
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--page-size int 每页返回的评论数量,默认 50,最大 50
|
||||
--next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
|
||||
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
|
||||
```
|
||||
|
||||
### 创建文档评论
|
||||
```
|
||||
Usage:
|
||||
dws doc comment create [flags]
|
||||
Example:
|
||||
dws doc comment create --node <DOC_ID> --content "这里需要修改"
|
||||
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--content string 评论的文字内容,纯文本 (必填)
|
||||
--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:
|
||||
dws doc comment reply [flags]
|
||||
Example:
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
|
||||
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
|
||||
--emoji 设为 true 时作为表情贴图回复 (默认 false)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
## URL 识别与 DOC_ID 提取
|
||||
|
||||
当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。
|
||||
|
||||
### 支持的 URL 格式
|
||||
|
||||
| 格式 | 示例 | DOC_ID 提取方式 |
|
||||
|------|------|----------------|
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
|
||||
|
||||
### 提取规则
|
||||
|
||||
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
|
||||
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
|
||||
### 处理流程
|
||||
|
||||
```
|
||||
用户输入含 alidocs.dingtalk.com URL
|
||||
→ 提取 DOC_ID(URL 路径最后一段)
|
||||
→ 结合用户意图选择命令(默认 read)
|
||||
→ 将 DOC_ID 传给 --node 参数
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"找文档/搜文档/最近文档":
|
||||
- 搜索 → `search`
|
||||
- 浏览 → `list`
|
||||
|
||||
用户说"看文档/读内容/文档内容":
|
||||
- 读取 → `read` (需文档 ID 或 URL)
|
||||
- 元信息 → `info`
|
||||
|
||||
用户说"写文档/创建文档":
|
||||
- 新建纯文档 (adoc) → `create`
|
||||
- 追加内容 → `update --mode append`
|
||||
- 覆盖替换 → `update --mode overwrite`
|
||||
|
||||
用户说"新建表格/脑图/白板/多维表/演示文稿":
|
||||
- 用 `file create --type` 指定类型 (axls/amind/adraw/able/appt/adraw)
|
||||
|
||||
用户说"建文件夹/新建目录":
|
||||
- 创建 → `folder create` 或 `file create --type folder`
|
||||
|
||||
用户说"复制文档/拷贝一份":
|
||||
- 复制 → `copy` (需源 --node 和目标 --folder/--workspace)
|
||||
|
||||
用户说"移动文档/换个目录":
|
||||
- 移动 → `move` (需源 --node 和目标 --folder/--workspace)
|
||||
|
||||
用户说"改文档名字/重命名":
|
||||
- 改名 → `rename` (需 --node 和新 --name)
|
||||
|
||||
用户说"上传文件/传文件/上传到文档/上传到知识库":
|
||||
- 上传 → `upload`(需本地文件路径)
|
||||
- 上传并转换 → `upload --convert`
|
||||
|
||||
用户说"下载文件/导出文件/下载到本地":
|
||||
- 下载 → `download`(需文件节点 ID 或 URL)
|
||||
|
||||
用户说"编辑块/改段落/插入标题/删除块":
|
||||
- 查看结构 → `block list`
|
||||
- 插入 → `block insert`
|
||||
- 修改 → `block update`
|
||||
- 删除 → `block delete`
|
||||
|
||||
**用户直接粘贴文档 URL(无其他指令)**:
|
||||
- 默认 → `read`(读取文档内容)
|
||||
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
|
||||
|
||||
**用户粘贴 URL + 附加指令**:
|
||||
- "帮我看看这个文档" → `read`
|
||||
- "这个文档的信息" → `info`
|
||||
- "往这个文档追加内容" → `update --mode append`
|
||||
- "编辑这个文档的标题" → `block update`
|
||||
|
||||
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# ── 工作流 1: 浏览并阅读文档 ──
|
||||
|
||||
# 1. 浏览我的文档根目录
|
||||
dws doc list --format json
|
||||
|
||||
# 2. 浏览子文件夹
|
||||
dws doc list --folder <FOLDER_ID> --format json
|
||||
|
||||
# 3. 获取文档元信息 (标题、类型、权限)
|
||||
dws doc info --node <DOC_ID> --format json
|
||||
|
||||
# 4. 读取文档内容 (Markdown 格式)
|
||||
dws doc read --node <DOC_ID> --format json
|
||||
|
||||
# ── 工作流 2: 创建文档并写入内容 ──
|
||||
|
||||
# 1. (可选) 创建文件夹 — 提取 nodeId
|
||||
dws doc folder create --name "项目资料" --format json
|
||||
|
||||
# 2. 创建文档 — 提取 nodeId
|
||||
dws doc create --name "项目周报" --folder <FOLDER_ID> --format json
|
||||
|
||||
# 3. 写入内容 (追加模式)
|
||||
dws doc update --node <DOC_ID> --markdown "# 本周总结\n\n- 完成了 A\n- 推进了 B" --mode append --format json
|
||||
|
||||
# ── 工作流 3: 一步创建带内容的文档 ──
|
||||
|
||||
dws doc create --name "会议纪要" --markdown "# 会议纪要\n\n## 议题\n\n1. ..." --format json
|
||||
|
||||
# ── 工作流 4: 上传本地文件到钉钉文档/知识库 ──
|
||||
|
||||
# 1. 上传到"我的文档"根目录
|
||||
dws doc upload --file ./report.pdf
|
||||
|
||||
# 2. 上传到指定文件夹
|
||||
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
|
||||
|
||||
# 3. 上传到知识库并转换为在线文档
|
||||
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
|
||||
|
||||
# ── 工作流 5: 下载文件到本地 ──
|
||||
|
||||
# 1. 下载到当前目录 (自动推断文件名)
|
||||
dws doc download --node <NODE_ID>
|
||||
|
||||
# 2. 下载到指定路径
|
||||
dws doc download --node <NODE_ID> --output ./report.pdf
|
||||
|
||||
# 3. 下载到指定目录 (自动推断文件名)
|
||||
dws doc download --node <NODE_ID> --output ~/downloads/
|
||||
|
||||
# ── 工作流 6: 块级精细编辑 ──
|
||||
|
||||
# 1. 查看文档块结构 — 获取 blockId
|
||||
dws doc block list --node <DOC_ID> --format json
|
||||
|
||||
# 2. 在文档末尾插入段落
|
||||
dws doc block insert --node <DOC_ID> --text "新增内容"
|
||||
|
||||
# 3. 在指定块之前插入标题
|
||||
dws doc block insert --node <DOC_ID> --heading "新章节" --level 2 --ref-block <BLOCK_ID> --where before
|
||||
|
||||
# 4. 更新某个块的内容
|
||||
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后的内容"
|
||||
|
||||
# 5. 删除块
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
|
||||
|
||||
# ── 工作流 7: 文档评论管理 ──
|
||||
|
||||
# 1. 查看文档的所有评论
|
||||
dws doc comment list --node <DOC_ID> --format json
|
||||
|
||||
# 2. 在文档上创建全文评论
|
||||
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
|
||||
|
||||
# 3. 创建评论并 @ 相关人
|
||||
# 先搜索用户: dws contact user search --query "张三" --format json → 提取 userId
|
||||
# 再将 userId 传入 --mention
|
||||
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | `nodes[].nodeId` | read / info / update / block 操作的 --node |
|
||||
| `list` | folder 类型的 `nodeId` | list 的 --folder, create 的 --folder |
|
||||
| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 |
|
||||
| `create` | `nodeId` | update / block 操作的 --node |
|
||||
| `folder create` | `nodeId` | create / list / upload 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
|
||||
| `download` | 本地文件路径 | 下载后的文件保存位置 |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
| `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 双格式说明
|
||||
|
||||
所有 `--node` 参数同时支持两种格式,系统自动识别:
|
||||
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
|
||||
两种方式等价,以下命令效果相同:
|
||||
```bash
|
||||
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
|
||||
```
|
||||
|
||||
`--folder` 参数同样支持文件夹 URL 或 ID。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `update --mode overwrite` 会**清空原内容后重写**,⚠️ 谨慎使用;默认 `--mode append` (追加) 更安全
|
||||
- `read` 返回 Markdown 格式的文档内容,仅限有"下载"权限的文档
|
||||
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
|
||||
- `block list/insert/update/delete` 是块级精细编辑,适合结构化修改;简单内容追加建议用 `update --mode append`
|
||||
- `block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型 (table, callout 等) 使用 `--element` JSON
|
||||
- `markdown` 参数中的换行必须使用**真实换行符**(即实际的换行字符,Unicode `U+000A`),而不是字面量字符串 `\n`(反斜杠加字母 n)。在通过程序或大模型构造此参数时,请确保字符串在发送前已正确反转义。如果传入的是两个字符的字面量 `\n`,所有内容将渲染在同一行,导致标题、段落和表格格式全部错乱。
|
||||
- 块类型包括: paragraph, heading, blockquote, callout, columns, orderedList, unorderedList, table, sheet, attachment, slot
|
||||
- 关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
|
||||
- `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 开始)
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
|
||||
- [drive](./drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
|
||||
- [report](./report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
|
||||
@@ -0,0 +1,177 @@
|
||||
# 钉盘 (drive) 命令参考
|
||||
|
||||
钉盘 = DingTalk Drive,用于云端文件存储 / 上传 / 下载 / 目录管理。不是在线文档编辑;要编辑文档请用 [doc](./doc.md)。
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 列出钉盘目录
|
||||
```
|
||||
Usage:
|
||||
dws drive list [flags]
|
||||
Example:
|
||||
dws drive list
|
||||
dws drive list --parent-id <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 链接语法)
|
||||
@@ -0,0 +1,160 @@
|
||||
# 邮箱 (mail) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 查询可用邮箱地址
|
||||
```
|
||||
Usage:
|
||||
dws mail mailbox list [flags]
|
||||
Example:
|
||||
dws mail mailbox list
|
||||
```
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
|
||||
|
||||
### 搜索邮件 (KQL 语法)
|
||||
```
|
||||
Usage:
|
||||
dws mail message search [flags]
|
||||
Example:
|
||||
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
|
||||
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
|
||||
Flags:
|
||||
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
|
||||
--email string 搜索目标邮箱地址 (必填)
|
||||
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
|
||||
--size string 每页返回数量(最大限制 100, 默认 20) (必填),别名: --limit, --page-size
|
||||
```
|
||||
|
||||
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
|
||||
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
|
||||
|
||||
### KQL 查询字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 正确示例 | 错误示例 |
|
||||
|------|------|------|----------|----------|
|
||||
| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) |
|
||||
| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) |
|
||||
| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` |
|
||||
| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) |
|
||||
| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) |
|
||||
| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` |
|
||||
| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) |
|
||||
| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) |
|
||||
| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) |
|
||||
| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:alice@company.com`、`from:"张 三"`、`from:"alice<a@b.com>"` | `from:张 三`(含空格未加引号) |
|
||||
| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:bob@company.com`、`to:"李 四"`、`to:"alice<a@b.com>"` | `to:李 四`(含空格未加引号) |
|
||||
|
||||
**组合查询说明:**
|
||||
- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写)
|
||||
- 括号用于分组:`(from:alice OR from:bob) AND folderId:2`
|
||||
- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)`
|
||||
|
||||
### message search 返回值说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) |
|
||||
| `total` | `int32` | 符合条件的总邮件数 |
|
||||
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 |
|
||||
|
||||
**翻页示例:**
|
||||
```bash
|
||||
# 第一页
|
||||
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
|
||||
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
|
||||
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
|
||||
```
|
||||
|
||||
### 查看邮件完整内容
|
||||
```
|
||||
Usage:
|
||||
dws mail message get [flags]
|
||||
Example:
|
||||
dws mail message get --email user@company.com --id <messageId>
|
||||
Flags:
|
||||
--email string 邮件所属邮箱地址 (必填)
|
||||
--id string 邮件 ID (必填)
|
||||
```
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
|
||||
|
||||
### 发送邮件
|
||||
```
|
||||
Usage:
|
||||
dws mail message send [flags]
|
||||
Example:
|
||||
dws mail message send --from user@company.com --to colleague@company.com \
|
||||
--subject "周报" --body "本周完成任务A和任务B"
|
||||
Flags:
|
||||
--body string 邮件正文,支持 Markdown 格式 (必填)
|
||||
--cc string 抄送人列表
|
||||
--from string 发件人邮箱 (必填),别名: --sender
|
||||
--subject string 邮件标题 (必填)
|
||||
--to string 收件人列表 (必填)
|
||||
```
|
||||
|
||||
## 通用错误说明
|
||||
|
||||
以下错误适用于所有 mail 命令。
|
||||
|
||||
| 错误标识 | 含义 | 处理建议 |
|
||||
|----------|------|----------|
|
||||
| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"我的邮箱/邮箱地址" → `mailbox list`
|
||||
用户说"找邮件/搜邮件/查邮件" → `message search`
|
||||
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
|
||||
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
|
||||
用户说"给(某人名字)发邮件" → 先 `aisearch person` 获取 userId,再 `contact user get` 获取收件人邮箱,再 `message send`
|
||||
|
||||
|
||||
## 严格禁止 (NEVER DO)
|
||||
- 明确禁止猜测、假设、推断发件人和收件人邮箱
|
||||
- 无法获取邮箱时,强引导ask_human,由用户确认,不要通过假设或其他方式继续执行
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 查看可用邮箱 — 提取邮箱地址
|
||||
dws mail mailbox list --format json
|
||||
|
||||
# 2. 搜索邮件 — 提取 messageId
|
||||
dws mail message search --email user@company.com \
|
||||
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
|
||||
|
||||
# 3. 查看邮件详情
|
||||
dws mail message get --email user@company.com --id <messageId> --format json
|
||||
|
||||
# 4. 发送邮件
|
||||
dws mail message send --from user@company.com --to colleague@company.com \
|
||||
--subject "周报" --body "本周完成…" --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `mailbox list` | 邮箱地址 | message search/get/send 的 --email/--from |
|
||||
| `message search` | `messageId` | message get 的 --id |
|
||||
| `aisearch person` → `contact user get` | 用户邮箱 (orgAuthEmail) | message send 的 --to/--cc (跨产品) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。选择邮箱时优先匹配用户当前所在企业的企业邮箱;若有多个可选,向用户确认后再操作
|
||||
- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容
|
||||
- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号
|
||||
- `--cc` 抄送人支持多人,逗号分隔
|
||||
- 收件人邮箱获取:用户只知道同事名字时,先通过 `dws aisearch person --keyword "名字" --dimension name` 获取 userId,再 `dws contact user get --ids <userId>` 从返回中提取 orgAuthEmail 字段
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user