Compare commits

..
Author SHA1 Message Date
修雨 cb200af3b2 Merge pull request #771 from DingTalk-Real-AI/codex/release-v1.0.55-beta.1
chore(release): prepare v1.0.55-beta.1
2026-07-23 13:43:58 +08:00
修雨 cf5b76de07 chore(release): prepare v1.0.55-beta.1 2026-07-23 13:35:30 +08:00
修雨 423e16ced0 Merge pull request #767 from DingTalk-Real-AI/codex/align-chat-file-upload
fix(chat): align local file sending with Wukong
2026-07-23 13:25:10 +08:00
修雨 c1a4bd6781 fix(chat): preserve interface while retiring discovery 2026-07-23 13:00:05 +08:00
修雨 02817bc043 Merge main and complete chat media retirement 2026-07-23 12:56:11 +08:00
修雨 9f76c1844a Merge pull request #697 from FloralTide/feat/mcp-url-get
feat(mcp): add URL resolution command
2026-07-23 11:59:34 +08:00
炳昱 857d9b8c1b test(mcp): cover URL command error paths 2026-07-23 11:31:12 +08:00
炳昱 a7fdcea086 test(cli): update public interface baseline 2026-07-23 10:19:14 +08:00
炳昱 9fc63b6405 fix(mcp): expose URL command in schema 2026-07-23 00:46:14 +08:00
炳昱 3233e1fe93 feat(mcp): add URL resolution command 2026-07-23 00:46:14 +08:00
修雨 f7e61feacf Merge remote-tracking branch 'origin/main' into codex/align-chat-file-upload
# Conflicts:
#	CHANGELOG.md
2026-07-23 00:45:11 +08:00
修雨 cdc3fbe328 test(chat): cover ID routing helpers 2026-07-23 00:38:50 +08:00
Dennis4477 b4ea1f168d fix(chat): render cards, forwards and encrypted messages
Normalize message projections across read shortcuts, preserve mixed user JSON, expand forwarded records, mask ciphertext, and accept media-download message ID aliases while retaining the Cobra/Schema required contract.
2026-07-23 00:18:46 +08:00
修雨 b03017997d fix(schema): preserve chat interface contract 2026-07-23 00:11:41 +08:00
修雨 902e084d8a fix(chat): align local file sending with wukong 2026-07-23 00:03:58 +08:00
修雨 412e77f215 Merge pull request #763 from DingTalk-Real-AI/codex/retry-gitee-transient-outages
fix: retry transient Gitee read outages safely
2026-07-22 17:56:50 +08:00
修雨 2a0bf1ebea fix: retry transient Gitee read outages safely 2026-07-22 17:46:03 +08:00
修雨 9ce13da6ed Merge pull request #762 from DingTalk-Real-AI/codex/extend-gitee-upload-window
fix: extend Gitee upload window
2026-07-22 16:50:49 +08:00
修雨 0e5731166b fix: extend Gitee upload window 2026-07-22 16:39:55 +08:00
修雨 92edd8ea53 Merge pull request #761 from DingTalk-Real-AI/codex/fix-gitee-slow-upload-timeout
fix: allow slow Gitee binary uploads
2026-07-22 16:08:28 +08:00
修雨 931d75beaf fix: allow slow Gitee binary uploads 2026-07-22 15:57:21 +08:00
修雨 7667cb30a3 Merge pull request #759 from DingTalk-Real-AI/codex/fix-gitee-upload-expect
fix: disable Expect for Gitee uploads
2026-07-22 15:13:33 +08:00
修雨 015daae064 fix: disable Expect for Gitee uploads 2026-07-22 15:02:13 +08:00
修雨 e7510ea5f0 Merge pull request #758 from DingTalk-Real-AI/codex/fix-gitee-upload-timeouts
fix: harden Gitee release repair
2026-07-22 14:39:15 +08:00
修雨 582b73cb40 fix: harden Gitee release repair 2026-07-22 14:27:47 +08:00
修雨 04ea184ff6 Merge pull request #752 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.54-beta.2
chore: update Homebrew beta formula for v1.0.54-beta.2
2026-07-22 14:12:31 +08:00
修雨 0908b2ca6e Merge branch 'main' into automation/homebrew-beta-v1.0.54-beta.2 2026-07-22 11:48:29 +08:00
修雨 070febd7bf Merge pull request #755 from DingTalk-Real-AI/automation/homebrew-v1.0.54
chore: update Homebrew formula for v1.0.54
2026-07-22 11:47:19 +08:00
DWS Release Bot e3782231be chore: update formula for v1.0.54 2026-07-21 16:07:10 +00:00
DWS Release Bot 167a547a65 chore: update beta formula for v1.0.54-beta.2 2026-07-21 15:55:51 +00:00
修雨 8f62c19104 Merge pull request #749 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.2
docs(changelog): add v1.0.54-beta.2 section
2026-07-21 23:37:15 +08:00
修雨 82798dc7fc docs(changelog): add v1.0.54-beta.2 section 2026-07-21 23:35:36 +08:00
修雨 3319cf62d5 Merge pull request #748 from DingTalk-Real-AI/release/changelog-v1.0.54
docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section
2026-07-21 23:28:45 +08:00
修雨 1626818a98 docs(changelog): retain released v1.0.54-beta.1 section under v1.0.54 2026-07-21 23:26:23 +08:00
修雨 a03d6ebacc docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section 2026-07-21 23:23:40 +08:00
修雨 4ee4a44e16 Merge pull request #745 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.1
docs(changelog): add v1.0.54-beta.1 section
2026-07-21 23:00:03 +08:00
修雨 40181f8c0c docs(changelog): add v1.0.54-beta.1 section 2026-07-21 22:57:59 +08:00
修雨 14ff02ebe1 Merge pull request #743 from wxianfeng/fix/event-data-format-compat
fix(event): make flattened output opt-in
2026-07-21 22:50:21 +08:00
wxianfeng 55574fe12e Merge upstream/main into fix/event-data-format-compat 2026-07-21 22:37:26 +08:00
修雨 222ee16d51 test(event): close changed-code coverage gaps for flatten output mode
Drop the unreachable defensive tag-skip branch in transportEnvelopeSchema
(every transport.Event field carries a non-empty JSON tag) and add a unit
test for the validatePersonalEventOutputMode success path so the changed
code coverage gate reaches 100%.
2026-07-21 22:28:54 +08:00
修雨 27ced3ee18 Merge pull request #701 from DingTalk-Real-AI/codex/fix-plugin-command-registration
fix: restore plugin CLI overlay commands
2026-07-21 22:03:08 +08:00
修雨 129e8a10ef Merge remote-tracking branch 'origin/main' into codex/fix-plugin-command-registration
# Conflicts:
#	CHANGELOG.md
2026-07-21 21:50:37 +08:00
修雨 02fba09c1e fix(plugin): let replaceable fallbacks pass distribution conflict checks
pluginDescriptorConflictsWithDistribution and the identity-owner seeding
both treated conference as distribution-owned, so the whole plugin server
was skipped before the replaceable-fallback merge in addPluginCommandsSafe
could run. Skip replaceablePluginFallbacks names in both early gates while
keeping reserved-command protection and plugin-vs-plugin ownership intact.
2026-07-21 21:49:47 +08:00
修雨 94b64f74ac Merge pull request #738 from typefield/fix/schema-cli-path-compat
fix(schema): accept compatible CLI path separators
2026-07-21 21:37:10 +08:00
wxianfeng cefcf5b409 fix(event): make flattened output opt-in 2026-07-21 21:25:10 +08:00
玉澜 441289cdfe Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 20:50:37 +08:00
玉澜 d03823d772 test(schema): cover unknown compatibility query 2026-07-21 20:50:34 +08:00
修雨 c16a377863 Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:47:48 +08:00
修雨 31c3acc94b Merge pull request #718 from DingTalk-Real-AI/cleanup/remove-shortcut-eval-pii
chore: 移除含真实 PII 的 shortcut 评测产物
2026-07-21 20:47:19 +08:00
修雨 f7e702df8d Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:35:02 +08:00
玉澜 bee246e62c Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 19:50:20 +08:00
玉澜 089caa92a8 test(schema): cover prefixed compatibility query 2026-07-21 19:41:39 +08:00
玉澜 21cd3f8bc4 fix(schema): accept compatible CLI path separators 2026-07-21 19:18:07 +08:00
修雨 6c0cf3438b fix: address plugin review blockers 2026-07-21 11:33:03 +08:00
修雨 e3f30420fb fix: restore plugin overlay commands 2026-07-21 11:33:03 +08:00
119 changed files with 8744 additions and 1423 deletions

No files matched your search

+13 -4
View File
@@ -1735,7 +1735,7 @@ jobs:
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
needs: [release-contract, release, publish-channels]
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 360
permissions:
contents: read
env:
@@ -1752,12 +1752,14 @@ jobs:
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -1776,7 +1778,7 @@ jobs:
path: dist
- name: Mirror release to Gitee (China)
timeout-minutes: 100
timeout-minutes: 318
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ needs.release-contract.outputs.release_version }}
@@ -2180,13 +2182,14 @@ jobs:
needs: dispatch-contract
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tooling
@@ -2194,6 +2197,7 @@ jobs:
fetch-depth: 0
- name: Validate repair version
timeout-minutes: 2
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2208,6 +2212,7 @@ jobs:
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
timeout-minutes: 5
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
with:
@@ -2330,12 +2335,14 @@ jobs:
- name: Check out sealed release source
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2352,6 +2359,7 @@ jobs:
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Release workflow delivery
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2368,6 +2376,7 @@ jobs:
--channel-repair "$target" "$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2385,7 +2394,7 @@ jobs:
- name: Mirror release to Gitee (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}
timeout-minutes: 100
timeout-minutes: 318
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
+4
View File
@@ -62,3 +62,7 @@ dwsbin
/docs/shortcut-comparison.html
/docs/shortcut-gsb-eval.*
/scripts/run_shortcut_real_read_matrix.py
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
+53
View File
@@ -6,6 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.55-beta.1] - 2026-07-23
This beta validates MCP Market URL resolution, the supported Wukong local-file
send path after retiring the legacy credential-based media upload command from
discovery, and reliable message-read rendering for rich content, forwarded
records, encrypted messages, and media-download ID aliases.
### Added
- **MCP URL resolution** — adds `dws mcp url get <mcpId>` for resolving a DingTalk MCP Market ID to the current user and organization scoped Streamable HTTP URL, while keeping the helper-only `mcp-meta` endpoint out of the public product command surface.
### Changed
- **Chat local-file sending** — hides the open-source-only `chat media upload` compatibility command from Help, Schema, and bundled Skills, and removes its legacy AppKey/AppSecret OAPI path. Historical argv still receives an actionable migration error. Send local images and files through `chat message send --msg-type file --file-path`; callers that already hold a mediaId may continue to use `--msg-type image --media-id`.
### Fixed
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
## [1.0.54] - 2026-07-21
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.54-beta.2] - 2026-07-21
This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery.
### Changed
- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701).
## [1.0.54-beta.1] - 2026-07-21
This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.53] - 2026-07-21
This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.53-beta.4"
version "1.0.54-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-arm64.tar.gz"
sha256 "46b57bed1f6e9f7ba007d8a86a6f5eb280fdeb557fc9bb5946f14f9b1f8f0c9f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-amd64.tar.gz"
sha256 "1b7fd08e64b1c86bbcee217604ffe07e0e8f1b3b5c4de518534386972bcf0f9b"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-arm64.tar.gz"
sha256 "108d3861ef606519f9934530d29654eab55a73607d1ee6775461f98ef5a6acd4"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-amd64.tar.gz"
sha256 "6cb96ee09419bbbcc1eb336218ac2aa1d9ca0ed5cbd5a80c79bc20e1e1f03ff7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-skills.zip"
sha256 "572b93f04a10268d185ad1f8e70e0d412949ae056be8494a9949387076fd14bc"
end
def install
+13 -11
View File
@@ -1,32 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52"
version "1.0.54"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
end
def install
@@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
+7 -5
View File
@@ -476,6 +476,8 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
For an event-focused installation, use the official convenience installer:
@@ -487,19 +489,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
+7 -5
View File
@@ -470,6 +470,8 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
只需要 event 能力时,可以使用官方便捷安装脚本:
@@ -481,19 +483,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
+9
View File
@@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
+2 -2
View File
@@ -1469,10 +1469,10 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
if !ok {
t.Fatal("mention definition missing")
}
if err := renderPersonalSchema(io.Discard, def, ""); err != nil {
if err := renderPersonalSchema(io.Discard, def, "", false); err != nil {
t.Fatal(err)
}
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
if err := renderPersonalSchema(io.Discard, def, "yaml", true); err == nil {
t.Fatal("unsupported schema format succeeded")
}
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
+10 -1
View File
@@ -112,6 +112,7 @@ func newEventConsumeCommand() *cobra.Command {
dryRun bool
foreground bool
asIdentity string
flatten bool
personalOpts personalConsumeOptions
streamOpts eventStreamTicketOptions
)
@@ -127,7 +128,11 @@ func newEventConsumeCommand() *cobra.Command {
json 每事件多行美化 JSON(必须配 --max-events 或 --duration)
pretty 同 json,未来加颜色
raw 仅 SDK 原始 payload,无外层封装
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor
数据结构:
ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)
--flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
@@ -144,6 +149,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.Flatten = flatten
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
@@ -167,6 +173,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
}
if err := rejectChangedFlags(c, "user",
"flatten",
"subscribe-id",
"rule",
"name",
@@ -280,6 +287,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)")
f.StringVarP(&formatRaw, "format", "f", "ndjson",
"输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson")
f.BoolVar(&flatten, "flatten", false,
"将个人事件 transport envelope 投影为稳定的顶层业务字段")
f.StringVar(&outputDir, "output-dir", "",
"每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥")
f.StringArrayVar(&routesRaw, "route", nil,
+39 -15
View File
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
Flatten bool
DebugRawEvents bool
SubscribeID string
Rule string
@@ -140,6 +141,7 @@ var (
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
var flatten bool
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
@@ -157,11 +159,12 @@ func newEventSchemaCommand() *cobra.Command {
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
@@ -189,7 +192,7 @@ func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
@@ -199,7 +202,7 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string) e
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
@@ -207,6 +210,19 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
@@ -221,16 +237,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
projector := personalEventProjector(opts.DebugRawEvents)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
@@ -247,6 +253,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -303,6 +310,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -366,11 +374,27 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
func personalEventProjector(debugRawEvents bool) consume.Projector {
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
}
return personal.ProjectOutput
if flatten {
return personal.ProjectOutput
}
return nil
}
func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error {
if !flatten {
return nil
}
if debugRawEvents {
return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive")
}
if format == consume.FormatRaw {
return fmt.Errorf("--flatten and --format raw are mutually exclusive")
}
return nil
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
+71 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/spf13/cobra"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
@@ -52,11 +53,14 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
}
}
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
if personalEventProjector(false) == nil {
t.Fatal("normal personal consume projector = nil")
func TestPersonalEventProjectorSelectsExplicitModes(t *testing.T) {
if personalEventProjector(false, false) != nil {
t.Fatal("default personal consume should preserve transport envelope")
}
projector := personalEventProjector(true)
if personalEventProjector(false, true) == nil {
t.Fatal("flatten personal consume projector = nil")
}
projector := personalEventProjector(true, false)
if projector == nil {
t.Fatal("debug raw personal consume projector = nil")
}
@@ -74,6 +78,69 @@ func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
}
}
func TestEventConsumeFlattenRejectsRawModesBeforeIdentityResolution(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "raw format",
args: []string{personal.EventMention, "--flatten", "--format", "raw"},
want: "--flatten and --format raw are mutually exclusive",
},
{
name: "raw debug",
args: []string{personal.EventMention, "--flatten", "--debug-raw-events"},
want: "--flatten and --debug-raw-events are mutually exclusive",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
}
if strings.Contains(err.Error(), "login") || strings.Contains(err.Error(), "token") {
t.Fatalf("output-mode validation ran after identity resolution: %v", err)
}
})
}
}
func TestValidatePersonalEventOutputModeAllowsFlattenStructuredFormats(t *testing.T) {
for _, format := range []consume.Format{consume.FormatNDJSON, consume.FormatJSON, consume.FormatPretty, consume.FormatCompact} {
if err := validatePersonalEventOutputMode(true, false, format); err != nil {
t.Fatalf("validatePersonalEventOutputMode(true, false, %q) error = %v", format, err)
}
}
}
func TestEventConsumeFlattenFlagIsForwarded(t *testing.T) {
oldRun := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldRun })
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventMention, "--flatten", "--format", "compact"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !got.Flatten || got.Common.FormatRaw != "compact" {
t.Fatalf("forwarded options = %#v", got)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
+46 -3
View File
@@ -188,7 +188,44 @@ func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
}
}
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
func TestPersonalEventSchemaDefaultsToTransportEnvelope(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{personal.EventSingleChat})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v, want object", doc["schema"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
}
for _, field := range []string{"type", "seq", "event_type", "data", "headers", "subscribe_id"} {
if _, ok := props[field]; !ok {
t.Fatalf("default envelope schema missing %q: %#v", field, props)
}
}
for _, field := range []string{"content", "sender", "conversation_id", "timestamp"} {
if _, ok := props[field]; ok {
t.Fatalf("default envelope schema unexpectedly contains flat field %q", field)
}
}
}
func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
for _, eventKey := range []string{
personal.EventMention,
personal.EventSingleChat,
@@ -200,7 +237,7 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -316,7 +353,7 @@ func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
@@ -367,6 +404,9 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
if doc["event_key"] != personal.EventSingleChat {
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want default envelope path", doc["jq_root_path"])
}
}
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
@@ -469,6 +509,9 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
if _, ok := params["odid"]; ok {
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
}
if _, ok := params["flatten"]; !ok {
t.Fatalf("schema parameters missing flatten: %#v", params)
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
param, ok := params[name].(map[string]any)
if !ok {
+10 -4
View File
@@ -27,21 +27,27 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
// built-ins. Conflicts with built-ins are skipped inside Load.
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
if loadUserShortcuts {
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
}
}
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
// command tree; mergeTopLevelCommands folds each shortcut's service parent
// into the matching helper command so the `+leaf` sits alongside existing
// subcommands.
commands = append(commands, builtin.Commands()...)
if loadUserShortcuts {
commands = append(commands, builtin.Commands()...)
} else {
commands = append(commands, builtin.BaseCommands()...)
}
return mergeTopLevelCommands(commands)
}
+108
View File
@@ -0,0 +1,108 @@
// 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"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
const (
mcpMetaServerID = "mcp-meta"
mcpMetaURLTool = "get_mcp_server_url"
)
func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
group := &cobra.Command{
Use: "url",
Short: "管理 MCP 服务连接地址",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
cmd := &cobra.Command{
Use: "get <mcpId>",
Short: "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
Long: "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 " +
"Streamable HTTP 服务地址。\n\n" +
"安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用," +
"请勿分享到群聊、文档、邮件、代码仓库或日志。",
Example: " dws mcp url get 2480\n" +
" dws mcp url get 2480 --format json",
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if caller == nil {
return fmt.Errorf("MCP tool caller is not configured")
}
mcpID := strings.TrimSpace(args[0])
if mcpID == "" {
return fmt.Errorf("mcpId 不能为空")
}
result, err := caller.CallTool(cmd.Context(), mcpMetaServerID, mcpMetaURLTool, map[string]any{
"mcpId": mcpID,
})
if err != nil {
return fmt.Errorf("获取 MCP 服务地址: %w", err)
}
return writeMCPURLResult(cmd, result)
},
}
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "mcp_id",
Type: "string",
Description: "钉钉 MCP 市场中的 mcpId",
Required: true,
Index: 0,
})
return cmd
}
func writeMCPURLResult(cmd *cobra.Command, result *edition.ToolResult) error {
if result == nil {
return fmt.Errorf("MCP 元服务返回空结果")
}
// get_mcp_server_url returns one JSON document in its first non-empty text
// block. Other block types and trailing blocks are intentionally ignored.
for _, block := range result.Content {
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
continue
}
if err := apperrors.ClassifyMCPResponseText(block.Text); err != nil {
return err
}
var payload any
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
return fmt.Errorf("MCP 元服务返回了无效 JSON: %w", err)
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
}
return fmt.Errorf("MCP 元服务返回空结果")
}
+194
View File
@@ -0,0 +1,194 @@
// 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"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type mcpURLTestCaller struct {
productID string
toolName string
args map[string]any
result *edition.ToolResult
err error
}
func (c *mcpURLTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.productID = productID
c.toolName = toolName
c.args = args
return c.result, c.err
}
func (*mcpURLTestCaller) Format() string { return "json" }
func (*mcpURLTestCaller) DryRun() bool { return false }
func (*mcpURLTestCaller) Fields() string { return "" }
func (*mcpURLTestCaller) JQ() string { return "" }
func executeMCPURLCommand(t *testing.T, caller edition.ToolCaller, args ...string) (string, error) {
t.Helper()
root := &cobra.Command{Use: "mcp", SilenceErrors: true, SilenceUsage: true}
root.AddCommand(newMCPURLGroup(caller))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.ExecuteContext(t.Context())
return out.String(), err
}
func TestMCPURLGetCallsMetaServerAndPreservesResponse(t *testing.T) {
const response = `{"result":{"mcpURL":"https://example.test/mcp?key=one&token=two","mcpJSON":{"transport":"streamable-http"},"name":"Example"}}`
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}},
}
out, err := executeMCPURLCommand(t, caller, "url", "get", " 10043 ")
if err != nil {
t.Fatalf("execute mcp url get: %v", err)
}
if caller.productID != mcpMetaServerID {
t.Fatalf("productID = %q, want %q", caller.productID, mcpMetaServerID)
}
if caller.toolName != mcpMetaURLTool {
t.Fatalf("toolName = %q, want %q", caller.toolName, mcpMetaURLTool)
}
if got := caller.args["mcpId"]; got != "10043" {
t.Fatalf("mcpId = %#v, want %q", got, "10043")
}
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
result, ok := payload["result"].(map[string]any)
if !ok {
t.Fatalf("output result = %#v", payload["result"])
}
if got := result["mcpURL"]; got != "https://example.test/mcp?key=one&token=two" {
t.Fatalf("result.mcpURL = %#v", got)
}
}
func TestMCPURLGetRejectsBlankID(t *testing.T) {
_, err := executeMCPURLCommand(t, &mcpURLTestCaller{}, "url", "get", " ")
if err == nil || !strings.Contains(err.Error(), "mcpId 不能为空") {
t.Fatalf("error = %v, want blank mcpId error", err)
}
}
func TestMCPURLGroupShowsHelp(t *testing.T) {
out, err := executeMCPURLCommand(t, nil, "url")
if err != nil {
t.Fatalf("execute mcp url: %v", err)
}
if !strings.Contains(out, "get") {
t.Fatalf("help output does not list get command:\n%s", out)
}
}
func TestMCPURLGetRejectsMissingCaller(t *testing.T) {
_, err := executeMCPURLCommand(t, nil, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "caller is not configured") {
t.Fatalf("error = %v, want missing caller error", err)
}
}
func TestMCPURLGetPropagatesCallError(t *testing.T) {
caller := &mcpURLTestCaller{err: errors.New("permission denied")}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("error = %v, want call error", err)
}
}
func TestMCPURLGetRejectsInvalidJSON(t *testing.T) {
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "not-json"}}},
}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "无效 JSON") {
t.Fatalf("error = %v, want invalid JSON error", err)
}
}
func TestMCPURLGetRejectsEmptyResults(t *testing.T) {
tests := []struct {
name string
result *edition.ToolResult
}{
{name: "nil result"},
{
name: "no usable text content",
result: &edition.ToolResult{Content: []edition.ContentBlock{
{Type: "image", Text: "ignored"},
{Type: "text", Text: " "},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &mcpURLTestCaller{result: tt.result}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil || !strings.Contains(err.Error(), "返回空结果") {
t.Fatalf("error = %v, want empty result error", err)
}
})
}
}
func TestMCPURLGetClassifiesBusinessError(t *testing.T) {
caller := &mcpURLTestCaller{
result: &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"success":false,"errorMsg":"搜索内容不能为空"}`,
}}},
}
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
if err == nil {
t.Fatal("expected classified business error")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "business_error" {
t.Fatalf("error = %#v, want classified business error", err)
}
}
func TestRootRegistersMCPURLGet(t *testing.T) {
root := NewRootCommand(t.Context())
mcp, _, err := root.Find([]string{"mcp"})
if err != nil {
t.Fatalf("find mcp: %v", err)
}
if mcp.Hidden {
t.Fatal("mcp command must be public when it contains reviewed public helpers")
}
cmd, _, err := root.Find([]string{"mcp", "url", "get"})
if err != nil {
t.Fatalf("find mcp url get: %v", err)
}
if got := cmd.CommandPath(); got != "dws mcp url get" {
t.Fatalf("command path = %q, want %q", got, "dws mcp url get")
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,675 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginFailRunner struct{}
func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("runner failed")
}
type pluginWrongFlagValue struct{}
func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("invalid root produced commands %#v", commands)
}
descriptor := conferencePluginDescriptor()
descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{
"empty": {Description: "removed when no leaf survives"},
}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"": {CLIName: "blank-tool"},
"hidden": {CLIName: "hidden", Hidden: true},
"invalid": {CLIName: "Invalid Leaf"},
"first": {CLIName: "same"},
"second": {CLIName: "same"},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 {
t.Fatalf("commands = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "same") == nil {
t.Fatal("valid leaf was not retained")
}
if requireOptionalPluginChild(commands[0], "empty") != nil {
t.Fatal("empty group was not pruned")
}
empty := conferencePluginDescriptor()
empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"hidden": {CLIName: "hidden", Hidden: true},
}
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("empty overlay produced commands %#v", commands)
}
}
func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) {
base := conferencePluginDescriptor()
base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"wrapped": {
CLIName: "wrapped",
BodyWrapper: "body",
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Required: true},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...)
root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`})
if err := root.Execute(); err != nil {
t.Fatalf("wrapped command: %v", err)
}
want := map[string]any{
"_meta": "kept",
"body": map[string]any{"old": float64(1), "value": "ok"},
}
if !reflect.DeepEqual(runner.invocations[0].Params, want) {
t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want)
}
for _, testCase := range []struct {
name string
runner executor.Runner
args []string
}{
{name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}},
{name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}},
{name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}},
{name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}},
} {
t.Run(testCase.name, func(t *testing.T) {
commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...)
commandRoot.SetArgs(testCase.args)
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected command error")
}
})
}
for _, flagName := range []string{"json", "params"} {
t.Run("unreadable "+flagName, func(t *testing.T) {
commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil)
leaf := requirePluginChild(t, commands[0], "wrapped")
leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{}
commandRoot := pluginTestRoot(commands...)
commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"})
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected unreadable flag error")
}
})
}
}
func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) {
reservations := pluginFlagReservations{
names: map[string]bool{"reserved": true},
shorthands: map[string]bool{},
}
bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}},
},
}, reservations)
if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) {
t.Fatalf("alias bindings = (%#v, %v)", bindings, ok)
}
if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}},
}, reservations); ok {
t.Fatal("reserved flag was accepted")
}
if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}},
}, reservations); ok {
t.Fatal("negative positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 0},
},
}, reservations); ok {
t.Fatal("duplicate positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1},
},
}, reservations); ok {
t.Fatal("non-contiguous positional indexes were accepted")
}
for _, testCase := range []struct {
name string
flags map[string]mcptypes.CLIFlagOverride
wantUse string
valid []string
invalid []string
}{
{
name: "exact",
flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1, Required: true},
"first": {Positional: true, PositionalIndex: 0, Required: true},
},
wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"},
},
{
name: "range",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0, Required: true},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{},
},
{
name: "maximum",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"},
},
} {
t.Run(testCase.name, func(t *testing.T) {
_, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations)
if !ok || use != testCase.wantUse {
t.Fatalf("binding contract = (%q, %v)", use, ok)
}
cmd := &cobra.Command{Use: testCase.name}
if err := validator(cmd, testCase.valid); err != nil {
t.Fatalf("valid args: %v", err)
}
if err := validator(cmd, testCase.invalid); err == nil {
t.Fatal("invalid args were accepted")
}
})
}
}
func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{
"integer": {Default: "2", Shorthand: "i", Hidden: true},
"float": {Default: "1.5"},
"boolean": {Default: "true"},
"slice": {Default: "one, ,two"},
"json": {Default: `{"old":true}`},
"string": {Default: "text"},
}}
bindings := []pluginFlagBinding{
{property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt},
{property: "float", names: []string{"float"}, kind: pluginFlagFloat},
{property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool},
{property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice},
{property: "json", names: []string{"json-value"}, kind: pluginFlagJSON},
{property: "string", names: []string{"string"}, kind: pluginFlagString},
}
registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}})
for name, raw := range map[string]string{
"integer": "3", "float": "2.5", "boolean": "false",
"slice": "three,four", "json-value": `{"ok":true}`, "string": "changed",
} {
if err := cmd.Flags().Set(name, raw); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
wants := map[string]any{
"integer": 3,
"float": 2.5,
"boolean": false,
"slice": []string{"three", "four"},
"json": map[string]any{"ok": true},
"string": "changed",
}
for _, binding := range bindings {
value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind)
if err != nil || !reflect.DeepEqual(value, wants[binding.property]) {
t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property])
}
}
if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden {
t.Fatal("hidden primary or alias flag was exposed")
}
if err := cmd.Flags().Set("json-value", "{"); err != nil {
t.Fatal(err)
}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("invalid JSON flag was accepted")
}
cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("wrong JSON flag type was accepted")
}
}
func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) {
t.Run("sources", func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "")
if err := cmd.Flags().Set("flag", "from-flag"); err != nil {
t.Fatal(err)
}
t.Setenv("PLUGIN_COVERAGE_ENV", "7")
params := map[string]any{"existing": "from-json"}
bindings := []pluginFlagBinding{
{property: "flag", names: []string{"flag"}, kind: pluginFlagString},
{property: "existing", kind: pluginFlagString},
{property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0},
{property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"},
{property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"},
{property: "optional", kind: pluginFlagString},
}
if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil {
t.Fatal(err)
}
want := map[string]any{
"flag": "from-flag", "existing": "from-json", "positional": true,
"default": 1.5, "env": 7,
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %#v, want %#v", params, want)
}
})
for _, testCase := range []struct {
name string
prepare func(t *testing.T, cmd *cobra.Command)
args []string
binding pluginFlagBinding
params map[string]any
}{
{
name: "wrong flag type",
prepare: func(t *testing.T, cmd *cobra.Command) {
cmd.Flags().String("value", "", "")
if err := cmd.Flags().Set("value", "x"); err != nil {
t.Fatal(err)
}
},
binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt},
},
{name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}},
{name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}},
{
name: "invalid env",
prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") },
binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"},
},
{name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}},
{name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}},
{name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}},
} {
t.Run(testCase.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
if testCase.prepare != nil {
testCase.prepare(t, cmd)
}
if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil {
t.Fatal("expected binding error")
}
})
}
params := map[string]any{"value": ""}
if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{
property: "value", kind: pluginFlagString, omitWhen: "empty",
}}, params); err != nil {
t.Fatal(err)
}
if _, exists := params["value"]; exists {
t.Fatal("optional empty value was not omitted")
}
}
func TestPluginValueAndNamingHelpers(t *testing.T) {
parseCases := []struct {
kind pluginFlagKind
raw string
want any
}{
{pluginFlagInt, " 2 ", 2},
{pluginFlagFloat, " 2.5 ", 2.5},
{pluginFlagBool, "true", true},
{pluginFlagStringSlice, "one, ,two", []string{"one", "two"}},
{pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}},
{pluginFlagString, " raw ", " raw "},
}
for _, testCase := range parseCases {
got, err := parsePluginValue(testCase.raw, testCase.kind)
if err != nil || !reflect.DeepEqual(got, testCase.want) {
t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want)
}
}
for _, testCase := range []struct {
kind pluginFlagKind
raw string
}{
{pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"},
} {
if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil {
t.Fatalf("invalid %q was accepted", testCase.raw)
}
}
omitCases := []struct {
value any
mode string
want bool
}{
{nil, "", true}, {" ", "", true}, {[]string{}, "", true},
{"", "never", false}, {false, "zero", true}, {0, "zero", true},
{float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false},
{float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true},
{[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false},
{struct{}{}, "zero", false}, {false, "", false},
}
for _, testCase := range omitCases {
if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want {
t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want)
}
}
wrapPluginParams(nil, "body")
untouched := map[string]any{"value": 1}
wrapPluginParams(untouched, " ")
wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3}
wrapPluginParams(wrapped, "body")
wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3}
if !reflect.DeepEqual(wrapped, wantWrapped) {
t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped)
}
kinds := map[string]pluginFlagKind{
"int": pluginFlagInt, "integer": pluginFlagInt,
"float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat,
"bool": pluginFlagBool, "boolean": pluginFlagBool,
"stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice,
"array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice,
"json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString,
}
for raw, want := range kinds {
if got := pluginFlagKindFromString(raw); got != want {
t.Fatalf("kind %q = %v, want %v", raw, got, want)
}
}
used := map[string]bool{}
reserved := map[string]bool{"r": true}
if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] {
t.Fatalf("safe shorthand = %q / %#v", got, used)
}
for _, raw := range []string{"", "xy", "x", "r"} {
if got := safePluginShorthand(raw, used, reserved); got != "" {
t.Fatalf("unsafe shorthand %q = %q", raw, got)
}
}
baseReservations := pluginReservedFlags(nil)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().StringP("custom", "c", "", "")
rootReservations := pluginReservedFlags(root)
if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] {
t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations)
}
if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) {
t.Fatalf("aliases = %#v", got)
}
if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" {
t.Fatalf("derived name = %q", got)
}
if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" {
t.Fatalf("kebab name = %q", got)
}
for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} {
if validPluginKebabName(name) {
t.Fatalf("invalid kebab name %q was accepted", name)
}
}
if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") {
t.Fatal("name validation contract failed")
}
if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" {
t.Fatal("first non-empty string contract failed")
}
}
func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
for _, name := range []string{"a", "b", "c"} {
cmd.Flags().String(name, "", "")
}
applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}},
RequireOneOf: [][]string{{"a", "b"}, {"missing"}},
RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}},
})
bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}}
if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}},
RequireOneOf: [][]string{{"a"}},
RequireTogether: [][]string{{"b", "c"}},
}) {
t.Fatal("valid plugin constraints were rejected")
}
for _, invalid := range []mcptypes.CLIToolOverride{
{MutuallyExclusive: [][]string{{"a"}}},
{RequireOneOf: [][]string{{"missing"}}},
{RequireTogether: [][]string{{"a", "a"}}},
} {
if validPluginFlagConstraints(bindings, invalid) {
t.Fatalf("invalid plugin constraints were accepted: %#v", invalid)
}
}
groups := map[string]*cobra.Command{}
root := &cobra.Command{Use: "root"}
group := ensurePluginGroup(root, "parent.child", "child description", groups)
if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) {
t.Fatalf("group = %#v", group)
}
if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group {
t.Fatal("existing group was not reused")
}
for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} {
if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil {
t.Fatalf("invalid group path %q produced %#v", invalid, got)
}
}
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
t.Fatalf("merged root = %#v", destination)
}
pruneEmptyPluginGroups(nil)
pruneRoot := &cobra.Command{Use: "root"}
empty := cobracmd.NewGroupCommand("empty", "empty")
nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty")
nonEmpty.AddCommand(&cobra.Command{Use: "leaf"})
pruneRoot.AddCommand(empty, nonEmpty)
pruneEmptyPluginGroups(pruneRoot)
if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil {
t.Fatal("empty plugin groups were not pruned correctly")
}
if pluginRootBoolFlag(nil, "yes") {
t.Fatal("nil command reported a root flag")
}
noFlag := &cobra.Command{Use: "root"}
if pluginRootBoolFlag(noFlag, "yes") {
t.Fatal("missing flag reported true")
}
wrongType := &cobra.Command{Use: "root"}
wrongType.PersistentFlags().String("yes", "true", "")
if pluginRootBoolFlag(wrongType, "yes") {
t.Fatal("wrong flag type reported true")
}
boolRoot := &cobra.Command{Use: "root"}
boolRoot.PersistentFlags().Bool("yes", false, "")
if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
if !pluginRootBoolFlag(boolRoot, "yes") {
t.Fatal("true root flag was not observed")
}
if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") {
t.Fatalf("confirmation error = %v", err)
}
}
func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) {
overlays := []struct {
value mcptypes.CLIOverlay
want string
}{
{mcptypes.CLIOverlay{Parent: "root"}, "parent"},
{mcptypes.CLIOverlay{Group: "group"}, "group"},
{mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"},
{mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"},
{mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"},
{mcptypes.CLIOverlay{}, ""},
}
for _, testCase := range overlays {
if got := unsupportedPluginOverlay(testCase.value); got != testCase.want {
t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want)
}
}
tools := []struct {
value mcptypes.CLIToolOverride
want string
}{
{mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"},
{mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"},
{mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"},
{mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"},
{mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"},
{mcptypes.CLIToolOverride{}, ""},
}
for _, testCase := range tools {
if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported tool = %q, want %q", got, testCase.want)
}
}
flags := []struct {
value mcptypes.CLIFlagOverride
want string
}{
{mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"},
{mcptypes.CLIFlagOverride{Transform: "x"}, "transform"},
{mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"},
{mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"},
{mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"},
{mcptypes.CLIFlagOverride{Type: "mystery"}, "type"},
{mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"},
{mcptypes.CLIFlagOverride{}, ""},
}
for _, testCase := range flags {
if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported flag = %q, want %q", got, testCase.want)
}
}
for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} {
if !supportedPluginFlagType(value) {
t.Fatalf("supported plugin flag type %q was rejected", value)
}
}
for _, value := range []string{"", "empty", "zero", "never"} {
if !supportedPluginOmitMode(value) {
t.Fatalf("supported plugin omit mode %q was rejected", value)
}
}
}
func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) {
testCases := []struct {
name string
mutate func(*mcptypes.ServerDescriptor)
want string
}{
{name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"},
{name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""},
{name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"},
{name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}}
}, want: "groups"},
{name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}}
}, want: "tool"},
{name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}}
}, want: "serverOverride"},
{name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}}
}, want: ""},
{name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}}
}, want: ""},
{name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}}
}, want: "cliName"},
{name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}}
}, want: "group"},
{name: "flags", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}},
}}
}, want: "flags"},
{name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
}}
}, want: "constraints"},
{name: "valid", want: ""},
}
root := pluginTestRoot()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
if testCase.mutate != nil {
testCase.mutate(&descriptor)
}
if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want {
t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want)
}
})
}
}
+809
View File
@@ -0,0 +1,809 @@
// 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"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginCaptureRunner struct {
invocations []executor.Invocation
}
func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.invocations = append(r.invocations, invocation)
return executor.Result{Invocation: invocation}, nil
}
func conferencePluginDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
Description: "conference plugin",
Endpoint: "stdio://conference/conference-local",
Source: "plugin",
HasCLIMeta: true,
CLI: mcptypes.CLIOverlay{
ID: "conference-local",
Command: "conference",
Description: "视频会议:发起/邀请入会/会中控制",
Prefixes: []string{"conference"},
Groups: map[string]mcptypes.CLIGroupDef{
"camera": {Description: "摄像头控制"},
"mic": {Description: "麦克风控制"},
"share": {Description: "屏幕共享"},
},
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"create_conference": {
CLIName: "start",
Description: "发起即时会议",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Description: "会议标题"},
},
},
"get_conference_status": {
CLIName: "status",
Description: "查询当前会议状态",
},
"ai_end_meeting_for_all": {
CLIName: "end",
Description: "结束会议(所有人)",
IsSensitive: true,
},
"ai_open_camera": {
CLIName: "open",
Group: "camera",
Description: "打开摄像头",
},
"ai_mute_mic": {
CLIName: "mute",
Group: "mic",
Description: "静音自己",
},
"ai_share_desktop": {
CLIName: "start",
Group: "share",
Description: "开始共享桌面",
Flags: map[string]mcptypes.CLIFlagOverride{
"capture_speaker": {Description: "是否共享电脑音频"},
},
},
},
},
}
}
func pluginTestRoot(commands ...*cobra.Command) *cobra.Command {
root := &cobra.Command{
Use: "dws",
SilenceErrors: true,
SilenceUsage: true,
}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(commands...)
return root
}
func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
current := parent
for _, name := range names {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
t.Fatalf("missing plugin command %q below %q", name, current.CommandPath())
}
current = next
}
return current
}
func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) {
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)
if len(commands) != 1 {
t.Fatalf("plugin roots = %d, want 1", len(commands))
}
conference := commands[0]
if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" {
t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short)
}
if !cmdutil.IsPluginSourced(conference) {
t.Fatal("conference root is missing plugin provenance")
}
if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" {
t.Fatalf("camera group short = %q", got)
}
if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" {
t.Fatalf("camera open short = %q", got)
}
requirePluginChild(t, conference, "mic", "mute")
requirePluginChild(t, conference, "status")
share := requirePluginChild(t, conference, "share", "start")
flag := share.Flags().Lookup("capture-speaker")
if flag == nil || flag.Usage != "是否共享电脑音频" {
t.Fatalf("capture-speaker flag = %#v", flag)
}
root := pluginTestRoot(commands...)
root.SetArgs([]string{
"conference", "start",
"--json", `{"from_json":"kept","title":"json"}`,
"--params", `{"from_params":2,"title":"params"}`,
"--title", "验证会议",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("conference start: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d, want 1", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.Kind != "compat_invocation" ||
invocation.CanonicalProduct != "conference-local" ||
invocation.Tool != "create_conference" ||
!invocation.DryRun {
t.Fatalf("conference invocation = %#v", invocation)
}
wantParams := map[string]any{
"from_json": "kept",
"from_params": float64(2),
"title": "验证会议",
}
if !reflect.DeepEqual(invocation.Params, wantParams) {
t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams)
}
precedenceRunner := &pluginCaptureRunner{}
precedenceRoot := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
precedenceRunner,
nil,
)...)
precedenceRoot.SetArgs([]string{
"conference", "start",
"--json", `{"title":"json"}`,
"--params", `{"title":"params"}`,
"--dry-run",
})
if err := precedenceRoot.Execute(); err != nil {
t.Fatalf("conference payload precedence: %v", err)
}
if got := precedenceRunner.invocations[0].Params["title"]; got != "params" {
t.Fatalf("conference payload title = %#v, want --params value", got)
}
}
func TestPluginOverlayTypedFlags(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"typed_tool": {
CLIName: "typed",
Flags: map[string]mcptypes.CLIFlagOverride{
"conversationId": {Required: true, Description: "conversation"},
"enabled": {Type: "bool"},
"limit": {Type: "int"},
"tags": {Type: "stringSlice"},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...)
root.SetArgs([]string{
"conference", "typed",
"--conversation-id", "cid",
"--enabled=false",
"--limit", "3",
"--tags", "one,two",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("typed plugin command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.CanonicalProduct != "conference-local" {
t.Fatalf("canonical product = %q", invocation.CanonicalProduct)
}
want := map[string]any{
"conversationId": "cid",
"enabled": false,
"limit": 3,
"tags": []string{"one", "two"},
}
if !reflect.DeepEqual(invocation.Params, want) {
t.Fatalf("typed params = %#v, want %#v", invocation.Params, want)
}
}
func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
wantCalls int
wantDry bool
wantError bool
}{
{name: "blocked", args: []string{"conference", "end"}, wantError: true},
{name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true},
{name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...)
root.SetArgs(testCase.args)
err := root.Execute()
if testCase.wantError {
var appErr *apperrors.Error
if !errors.As(err, &appErr) ||
appErr.Category != apperrors.CategoryValidation ||
appErr.Reason != "confirmation_required" {
t.Fatalf("sensitive error = %#v", err)
}
} else if err != nil {
t.Fatalf("sensitive command: %v", err)
}
if len(runner.invocations) != testCase.wantCalls {
t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls)
}
if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry {
t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry)
}
})
}
}
func TestPluginOverlayMergesServersWithoutProbingHTTP(t *testing.T) {
isolatePluginRuntime(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
first := conferencePluginDescriptor()
first.Endpoint = server.URL
first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"one": {CLIName: "one"},
}
second := first
second.Key = "conference-extra"
second.DisplayName = "conference/conference-extra"
second.Endpoint = server.URL + "/extra"
second.CLI.ID = "conference-extra"
second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"two": {CLIName: "two"},
}
registerPluginHTTPServer(first)
registerPluginHTTPServer(second)
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil)
if len(commands) != 1 {
t.Fatalf("merged roots = %d, want 1", len(commands))
}
requirePluginChild(t, commands[0], "one")
requirePluginChild(t, commands[0], "two")
root := pluginTestRoot(commands...)
root.SetArgs([]string{"conference", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("conference help: %v", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls while building help = %d, want 0", got)
}
for _, command := range []string{"one", "two"} {
root.SetArgs([]string{"conference", command, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("conference %s: %v", command, err)
}
}
if len(runner.invocations) != 2 ||
runner.invocations[0].CanonicalProduct != "conference-local" ||
runner.invocations[1].CanonicalProduct != "conference-extra" {
t.Fatalf("merged routes = %#v", runner.invocations)
}
}
func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
fallback := &cobra.Command{Use: "conference", Hidden: true}
fallback.AddCommand(&cobra.Command{Use: "meeting"})
distribution := &cobra.Command{Use: "drive"}
root.AddCommand(fallback, distribution)
conference := buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)[0]
drive := &cobra.Command{Use: "drive"}
cmdutil.MarkPluginSource(drive)
addPluginCommandsSafe(root, []*cobra.Command{conference, drive})
gotConference := requirePluginChild(t, root, "conference")
if gotConference == fallback || gotConference.Hidden {
t.Fatalf("conference fallback was not replaced: %#v", gotConference)
}
requirePluginChild(t, gotConference, "status")
if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution {
t.Fatal("visible distribution command was replaced by a plugin")
}
}
func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name":"drive-hijack",
"version":"1.0.0",
"mcpServers":{
"drive":{
"type":"streamable-http",
"endpoint":"https://plugin.invalid/mcp",
"cli":{
"id":"drive-service",
"command":"drive-hijack",
"toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}}
}
}
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: "drive",
Endpoint: "https://distribution.invalid/mcp",
CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "drive"})
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("conflicting plugin commands = %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok ||
endpoint != "https://distribution.invalid/mcp" {
t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok)
}
}
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
isolatePluginRuntime(t)
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
var calls atomic.Int32
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
calls.Add(1)
AppendDynamicServer(conferencePluginDescriptor())
return buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)
}
base := NewSchemaSourceRootCommand()
if calls.Load() != 0 {
t.Fatalf("Schema source root loaded plugins %d times", calls.Load())
}
baseConference := requirePluginChild(t, base, "conference")
if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil {
t.Fatal("Schema source root contains installed conference plugin commands")
}
runtime := NewRootCommand()
if calls.Load() != 1 {
t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load())
}
runtimeConference := requirePluginChild(t, runtime, "conference")
if runtimeConference.Hidden {
t.Fatal("runtime conference plugin is hidden")
}
requirePluginChild(t, runtimeConference, "status")
}
func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func TestPluginDerivedNamesAndReservedAliases(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.Aliases = []string{"auth", "conf", "conf"}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"conference_getCurrentStatus": {},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) {
t.Fatalf("plugin aliases = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "get-current-status") == nil {
var names []string
for _, command := range commands[0].Commands() {
names = append(names, command.Name())
}
t.Fatalf("derived command missing, got %s", strings.Join(names, ", "))
}
}
func TestPluginFlagsCannotShadowHostControls(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
reservations := pluginReservedFlags(host)
for name := range reservations.names {
t.Run(name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
IsSensitive: true,
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: name},
},
},
}
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
host,
); len(commands) != 0 {
t.Fatalf("reserved host flag %q produced commands %#v", name, commands)
}
})
}
}
func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"safe": {
CLIName: "safe",
Flags: map[string]mcptypes.CLIFlagOverride{
"alpha": {Shorthand: "f"},
"bravo": {Shorthand: "h"},
"charlie": {Shorthand: "o"},
"delta": {Shorthand: "v"},
"echo": {Shorthand: "x"},
"foxtrot": {Shorthand: "y"},
},
},
}
runner := &pluginCaptureRunner{}
commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
host,
)
if len(commands) != 1 {
t.Fatalf("plugin commands = %#v", commands)
}
host.AddCommand(commands...)
leaf := requirePluginChild(t, commands[0], "safe")
for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} {
if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" {
t.Fatalf("--%s shorthand = %q, want empty", name, shorthand)
}
}
host.SetArgs([]string{"conference", "safe", "-h"})
if err := host.Execute(); err != nil {
t.Fatalf("plugin help: %v", err)
}
if len(runner.invocations) != 0 {
t.Fatalf("help executed plugin: %#v", runner.invocations)
}
}
func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"payload": {
CLIName: "payload",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Required: true},
"mode": {Default: "fallback"},
"enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"},
},
},
}
for _, testCase := range []struct {
name string
args []string
wantEnabled bool
}{
{
name: "flag satisfies dual positional",
args: []string{
"conference", "payload",
"--params", `{"title":"from-json","mode":"from-json"}`,
"--enabled-value=true",
"--dry-run",
},
wantEnabled: true,
},
{
name: "json beats positional",
args: []string{
"conference", "payload", "true",
"--params", `{"title":"from-json","mode":"from-json","enabled":false}`,
"--dry-run",
},
wantEnabled: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
nil,
)...)
root.SetArgs(testCase.args)
if err := root.Execute(); err != nil {
t.Fatalf("payload command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
params := runner.invocations[0].Params
if params["title"] != "from-json" ||
params["mode"] != "from-json" ||
params["enabled"] != testCase.wantEnabled {
t.Fatalf("payload params = %#v", params)
}
})
}
}
func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("alpha-plugin", `{
"name":"alpha-plugin",
"version":"1.0.0",
"mcpServers":{
"alpha":{
"type":"streamable-http",
"endpoint":"https://alpha.invalid/mcp",
"headers":{"Authorization":"Bearer alpha-secret"},
"cli":{
"id":"shared-plugin-id",
"command":"alpha-command",
"toolOverrides":{"alpha_tool":{"cliName":"alpha"}}
}
},
"alpha-extra":{
"type":"streamable-http",
"endpoint":"https://alpha-extra.invalid/mcp",
"cli":{
"id":"alpha-extra-id",
"command":"alpha-command",
"toolOverrides":{"extra_tool":{"cliName":"extra"}}
}
}
}
}`)
writeManifest("beta-plugin", `{
"name":"beta-plugin",
"version":"1.0.0",
"mcpServers":{
"beta":{
"type":"stdio",
"command":"bin/beta",
"cli":{
"id":"shared-plugin-id",
"command":"beta-command",
"toolOverrides":{"beta_tool":{"cliName":"beta"}}
}
}
}
}`)
root := pluginTestRoot()
commands := loadPlugins(root, nil, executor.EchoRunner{})
if len(commands) != 1 || commands[0].Name() != "alpha-command" {
t.Fatalf("plugin winner commands = %#v", commands)
}
requirePluginChild(t, commands[0], "alpha")
requirePluginChild(t, commands[0], "extra")
endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool")
if !ok || endpoint != "https://alpha.invalid/mcp" {
t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok)
}
extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool")
if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" {
t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok)
}
auth, ok := LookupPluginAuth("shared-plugin-id")
if !ok || auth.Token != "alpha-secret" {
t.Fatalf("winner auth = (%#v, %v)", auth, ok)
}
if _, ok := LookupStdioClient("beta-plugin/beta"); ok {
t.Fatal("losing stdio client was registered")
}
}
func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) {
for _, mutate := range []func(*mcptypes.ServerDescriptor){
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.RedirectTo = "drive"
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"source": {MapsTo: "target"},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", ServerOverride: "drive"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"Body.query": {},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", Group: "safe.bad_name"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
},
}
},
} {
descriptor := conferencePluginDescriptor()
mutate(&descriptor)
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
nil,
); len(commands) != 0 {
t.Fatalf("unsupported overlay produced commands %#v", commands)
}
}
}
func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("unsafe-http", `{
"name":"unsafe-http",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"streamable-http",
"endpoint":"https://unsafe.invalid/mcp",
"headers":{"Authorization":"Bearer unsafe-secret"},
"cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{
"unsafe_tool":{"cliName":"run","serverOverride":"drive"}
}}
}}
}`)
writeManifest("unsafe-stdio", `{
"name":"unsafe-stdio",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"stdio",
"command":"bin/unsafe",
"cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{
"unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}}
}}
}}
}`)
root := pluginTestRoot()
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("unsupported plugin descriptors produced commands %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok {
t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint)
}
if _, ok := LookupPluginAuth("unsafe-http-id"); ok {
t.Fatal("unsupported HTTP descriptor registered plugin auth")
}
if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok {
t.Fatal("unsupported stdio descriptor registered a client")
}
}
+239
View File
@@ -0,0 +1,239 @@
// 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"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func pluginToolInputSchema(
tools transport.ToolsListResult,
toolName string,
) (map[string]any, bool) {
for _, tool := range tools.Tools {
if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) {
return tool.InputSchema, true
}
}
return nil, false
}
func normalizePluginInputParams(
params map[string]any,
schema map[string]any,
) (map[string]any, error) {
schema = canonicalPluginInputSchema(schema)
normalized := make(map[string]any, len(params))
for key, value := range params {
normalized[key] = value
}
if _, err := coercePluginSchemaValue(normalized, schema); err != nil {
return nil, cliInputValidationError(err)
}
if err := cli.ValidateInputSchema(normalized, schema); err != nil {
return nil, err
}
return normalized, nil
}
func canonicalPluginInputSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return schema
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = clonePluginSchemaValue(key, value)
}
return cloned
}
func clonePluginSchemaValue(key string, value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for childKey, childValue := range typed {
cloned[childKey] = clonePluginSchemaValue(childKey, childValue)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = clonePluginSchemaValue(key, item)
}
return cloned
case []string:
cloned := make([]string, len(typed))
for index, item := range typed {
if key == "type" {
item = canonicalPluginSchemaType(item)
}
cloned[index] = item
}
return cloned
case string:
if key == "type" {
return canonicalPluginSchemaType(typed)
}
return typed
default:
return value
}
}
func canonicalPluginSchemaType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "bool":
return "boolean"
case "int":
return "integer"
case "float":
return "number"
default:
return value
}
}
func cliInputValidationError(err error) error {
if err == nil {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("input schema normalization failed: %v", err),
apperrors.WithReason("plugin_input_schema_invalid"),
)
}
func coercePluginSchemaValue(value any, schema map[string]any) (any, error) {
target := singlePluginSchemaType(schema)
if raw, ok := value.(string); ok {
trimmed := strings.TrimSpace(raw)
switch target {
case "bool", "boolean":
parsed, err := strconv.ParseBool(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err)
}
value = parsed
case "int", "integer":
parsed, err := strconv.Atoi(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err)
}
value = parsed
case "float", "number":
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err)
}
value = parsed
case "object":
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err)
}
if parsed == nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object")
}
value = parsed
case "array":
var parsed []any
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err)
}
} else if trimmed != "" {
for _, item := range strings.Split(trimmed, ",") {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
}
value = parsed
}
}
switch typed := value.(type) {
case map[string]any:
properties, _ := schema["properties"].(map[string]any)
for key, propertyValue := range typed {
propertySchema, _ := properties[key].(map[string]any)
if len(propertySchema) == 0 {
continue
}
coerced, err := coercePluginSchemaValue(propertyValue, propertySchema)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
typed[key] = coerced
}
return typed, nil
case []string:
items := make([]any, len(typed))
for index, item := range typed {
items[index] = item
}
value = items
}
if items, ok := value.([]any); ok {
itemSchema, _ := schema["items"].(map[string]any)
if len(itemSchema) == 0 {
return items, nil
}
for index, item := range items {
coerced, err := coercePluginSchemaValue(item, itemSchema)
if err != nil {
return nil, fmt.Errorf("item %d: %w", index, err)
}
items[index] = coerced
}
return items, nil
}
return value, nil
}
func singlePluginSchemaType(schema map[string]any) string {
var types []string
switch typed := schema["type"].(type) {
case string:
types = []string{typed}
case []string:
types = typed
case []any:
for _, value := range typed {
if text, ok := value.(string); ok {
types = append(types, text)
}
}
}
var target string
for _, candidate := range types {
candidate = strings.TrimSpace(candidate)
if candidate == "" || candidate == "null" {
continue
}
if target != "" && target != candidate {
return ""
}
target = candidate
}
return target
}
@@ -0,0 +1,229 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"errors"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) {
want := map[string]any{"type": "object"}
tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{
{Name: "other", InputSchema: map[string]any{"type": "string"}},
{Name: " create_conference ", InputSchema: want},
}}
got, ok := pluginToolInputSchema(tools, " create_conference ")
if !ok || !reflect.DeepEqual(got, want) {
t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want)
}
if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil {
t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok)
}
}
func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []string{"enabled"},
"properties": map[string]any{
"enabled": map[string]any{"type": []any{"null", "bool"}},
"count": map[string]any{"type": "int"},
"ratio": map[string]any{"type": "float"},
"settings": map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
"ids": map[string]any{
"type": []string{"array", "null"},
"items": map[string]any{"type": "int"},
},
"labels": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
"booleans": map[string]any{
"type": "array",
"items": map[string]any{"type": "bool"},
},
"ambiguous": map[string]any{"type": []string{"string", "int"}},
},
}
params := map[string]any{
"enabled": " true ",
"count": " 7 ",
"ratio": " 2.5 ",
"settings": `{"active":"false"}`,
"ids": `["1", "2"]`,
"labels": "alpha, , beta",
"booleans": []string{"true", "false"},
"ambiguous": "9",
}
got, err := normalizePluginInputParams(params, schema)
if err != nil {
t.Fatalf("normalizePluginInputParams() error = %v", err)
}
want := map[string]any{
"enabled": true,
"count": 7,
"ratio": 2.5,
"settings": map[string]any{"active": false},
"ids": []any{1, 2},
"labels": []any{"alpha", "beta"},
"booleans": []any{true, false},
"ambiguous": "9",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want)
}
properties := schema["properties"].(map[string]any)
if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" {
t.Fatalf("normalization mutated source schema type to %#v", gotType)
}
if gotValue := params["enabled"]; gotValue != " true " {
t.Fatalf("normalization mutated source params to %#v", gotValue)
}
}
func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) {
tests := []struct {
name string
value any
fieldSchema map[string]any
wantText string
}{
{name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"},
{name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"},
{name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"},
{name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"},
{name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"},
{name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"},
{
name: "nested property",
value: `{"active":"sometimes"}`,
fieldSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
wantText: "field: active:",
},
{
name: "array item",
value: "1,not-an-int",
fieldSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "int"},
},
wantText: "item 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{"field": tt.fieldSchema},
}
_, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema)
if err == nil {
t.Fatal("normalizePluginInputParams() error = nil, want conversion error")
}
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
appError.Reason != "plugin_input_schema_invalid" {
t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err)
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("conversion error = %q, want text %q", err, tt.wantText)
}
})
}
}
func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []any{"name"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
}
if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil ||
!strings.Contains(err.Error(), "$.name is required") {
t.Fatalf("required-field validation error = %v", err)
}
}
func TestPluginInputSchemaHelperEdges(t *testing.T) {
if got := canonicalPluginInputSchema(nil); got != nil {
t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got)
}
if got := clonePluginSchemaValue("minimum", 1); got != 1 {
t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got)
}
if got := cliInputValidationError(nil); got != nil {
t.Fatalf("cliInputValidationError(nil) = %v, want nil", got)
}
if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) {
t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err)
}
items := []any{"unchanged"}
if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) {
t.Fatalf("array without item schema = (%#v, %v)", got, err)
}
if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 {
t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err)
}
unknown := map[string]any{"unknown": "unchanged"}
if got, err := coercePluginSchemaValue(unknown, map[string]any{
"type": "object",
"properties": map[string]any{},
}); err != nil || !reflect.DeepEqual(got, unknown) {
t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err)
}
tests := []struct {
name string
schema map[string]any
want string
}{
{name: "missing", schema: map[string]any{}, want: ""},
{name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"},
{name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"},
{name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"},
{name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := singlePluginSchemaType(tt.schema); got != tt.want {
t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want)
}
})
}
for raw, want := range map[string]string{
" BOOL ": "boolean",
"Int": "integer",
"FLOAT": "number",
"custom": "custom",
} {
if got := canonicalPluginSchemaType(raw); got != want {
t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// 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"
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("conference/local", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error {
return nil
}
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "create_conference",
InputSchema: map[string]any{
"type": "object",
"required": []any{"title"},
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"capture_speaker": map[string]any{"type": "bool"},
},
"additionalProperties": false,
},
}},
}, nil
}
var calledParams map[string]any
runnerStdioCallTool = func(
_ *transport.StdioClient,
_ context.Context,
_ string,
params map[string]any,
) (transport.ToolCallResult, error) {
calledParams = params
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{
CanonicalProduct: "conference-local",
Tool: "create_conference",
Params: map[string]any{
"title": "schema validation",
"capture_speaker": "true",
},
}
result, err := runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
if err != nil {
t.Fatalf("stdio plugin execution: %v", err)
}
wantParams := map[string]any{
"title": "schema validation",
"capture_speaker": true,
}
if !reflect.DeepEqual(calledParams, wantParams) ||
!reflect.DeepEqual(result.Invocation.Params, wantParams) {
t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params)
}
calledParams = nil
invocation.Params = map[string]any{"capture_speaker": "true"}
_, err = runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
calledParams != nil {
t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams)
}
}
@@ -0,0 +1,369 @@
// 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.
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type schemaSourceContextKey struct{}
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
pluginLoads := 0
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
pluginLoads++
return nil
}
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
root := NewSchemaSourceRootCommand(wantContext)
if root.Context() != wantContext {
t.Fatal("Schema source root did not retain the caller context")
}
if pluginLoads != 0 {
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
}
}
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
previousDescriptors := rootPluginDescriptors
previousClients := rootPluginStdioClients
previousDescriptor := rootPluginStdioDescriptor
t.Cleanup(func() {
rootPluginDescriptors = previousDescriptors
rootPluginStdioClients = previousClients
rootPluginStdioDescriptor = previousDescriptor
})
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
client := transport.NewStdioClient("unused", nil, nil)
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
if owner == first {
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
}
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
}
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
if gotContext != wantContext {
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
}
if owner != first {
return nil
}
return []plugin.StdioServerClient{
{Key: "same", Client: client},
{Key: " alpha ", Client: client},
{Key: "invalid", Client: client},
}
}
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
if stdio.Key == "invalid" {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
}
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
if len(candidates) != 5 {
t.Fatalf("candidate count = %d, want 5", len(candidates))
}
gotKeys := make([]string, 0, len(candidates))
gotKinds := make([]string, 0, len(candidates))
for _, candidate := range candidates {
gotKeys = append(gotKeys, candidate.descriptor.Key)
if candidate.stdioClient == nil {
gotKinds = append(gotKinds, "http")
} else {
gotKinds = append(gotKinds, "stdio")
if candidate.stdioClient.Client != client {
t.Fatal("stdio candidate did not retain its client")
}
}
}
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
}
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
}
}
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
isolatePluginRuntime(t)
blank := mcptypes.ServerDescriptor{
Key: " ",
CLI: mcptypes.CLIOverlay{
ID: " ",
Command: " ",
Aliases: []string{"", " "},
},
}
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
t.Fatalf("blank descriptor claims = %#v, want none", claims)
}
if rootName := pluginDescriptorRootName(blank); rootName != "" {
t.Fatalf("blank descriptor root = %q", rootName)
}
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
accepted := selectPluginServerCandidates(
&cobra.Command{Use: "dws"},
[]pluginServerCandidate{
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
{owner: owner, descriptor: blank},
},
)
if len(accepted) != 1 {
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
}
if distributionRootOwns(nil, "visible") {
t.Fatal("nil root claimed a command")
}
root := &cobra.Command{Use: "dws"}
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
cmdutil.MarkPluginSource(pluginOwned)
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
if !distributionRootOwns(root, name) {
t.Errorf("distribution root did not claim %q", name)
}
}
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
if distributionRootOwns(root, name) {
t.Errorf("distribution root unexpectedly claimed %q", name)
}
}
}
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
isolatePluginRuntime(t)
SetDynamicServers([]mcptypes.ServerDescriptor{
{
Key: "conference",
Endpoint: "https://example.com/conference/mcp",
CLI: mcptypes.CLIOverlay{ID: "conference"},
},
{
Key: "chat",
Endpoint: "https://example.com/chat/mcp",
CLI: mcptypes.CLIOverlay{ID: "chat"},
},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
distributionProducts := DirectRuntimeProductIDs()
conferenceDescriptor := mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
}
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
t.Fatal("replaceable fallback identity blocked plugin server selection")
}
chatDescriptor := mcptypes.ServerDescriptor{
Key: "chat-local",
DisplayName: "chat/chat-local",
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
}
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
t.Fatal("non-replaceable distribution product no longer conflicts")
}
reservedDescriptor := mcptypes.ServerDescriptor{
Key: "auth-local",
DisplayName: "auth/auth-local",
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
}
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
t.Fatal("reserved command name no longer conflicts")
}
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
{owner: first, descriptor: conferenceDescriptor},
{
owner: second,
descriptor: mcptypes.ServerDescriptor{
Key: "conference-other",
DisplayName: "other/conference-other",
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
},
},
})
if len(accepted) != 1 {
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
}
if accepted[0].owner != first {
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
}
}
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "taken"})
command := &cobra.Command{
Use: "extension",
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
}
addPluginCommandsSafe(root, []*cobra.Command{
command,
{Use: "shared"},
{Use: "other", Aliases: []string{"extension"}},
})
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
}
if child := findDirectChild(root, "shared"); child != nil {
t.Fatal("an accepted alias was also registered as a plugin primary command")
}
other := findDirectChild(root, "other")
if other == nil || len(other.Aliases) != 0 {
t.Fatalf("later plugin aliases = %#v", other)
}
}
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("plugin/server", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
toolCalls := 0
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
listFailure := errors.New("list failed")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{}, listFailure
}
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
}
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
if toolCalls != 0 {
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
}
}
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
isolatePluginRuntime(t)
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: "broken-plugin",
MCPServers: map[string]*plugin.MCPServer{
"local": {CLI: json.RawMessage(`{`)},
},
},
}
server := plugin.StdioServerClient{
Key: "local",
Client: transport.NewStdioClient("unused", nil, nil),
}
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
}
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
}
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
t.Fatal("invalid stdio manifest registered a client")
}
}
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
t.Fatal(err)
}
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
t.Fatal("malformed shortcut fixture did not fail to load")
}
runner := executor.EchoRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{})
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
t.Fatal("legacy commands were dropped after a user shortcut load error")
}
}
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
for _, command := range root.Commands() {
if command.Name() == name {
return command
}
}
return nil
}
func assertPluginRuntimeError(
t *testing.T,
err error,
wantCategory apperrors.Category,
wantOperation string,
wantReason string,
) {
t.Helper()
var appError *apperrors.Error
if !errors.As(err, &appError) {
t.Fatalf("runtime error = %#v, want structured app error", err)
}
if appError.Category != wantCategory ||
appError.Operation != wantOperation ||
appError.Reason != wantReason {
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
}
}
+38 -2
View File
@@ -5,6 +5,7 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"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/mcptypes"
@@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) {
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
pluginAuthMu.Lock()
previousPluginAuth := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
StopAllStdioClients()
dynamicMu.Lock()
@@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) {
stdioMu.Lock()
stdioClients = previousStdio
stdioMu.Unlock()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPluginAuth
pluginAuthMu.Unlock()
})
}
@@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
isolatePluginRuntime(t)
marker := t.TempDir() + "/started"
pluginRoot := t.TempDir()
if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{
"id":"local",
"command":"lazy-stdio",
"groups":{"health":{"description":"health checks"}},
"toolOverrides":{"ping":{"cliName":"ping","group":"health"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
client := transport.NewStdioClient("/bin/sh", []string{
"-c", fmt.Sprintf("printf started > %q", marker),
}, nil)
p := &plugin.Plugin{
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
Root: t.TempDir(),
Manifest: plugin.Manifest{
Name: "lazy-stdio",
Description: "lazy stdio test",
MCPServers: map[string]*plugin.MCPServer{
"local": {
Type: "stdio",
Command: "unused",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
Root: pluginRoot,
}
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
root := pluginTestRoot(commands...)
root.SetArgs([]string{"lazy-stdio", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("lazy stdio help: %v", err)
}
requirePluginChild(t, commands[0], "health", "ping")
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("stdio process started during registration: stat error = %v", err)
+34 -44
View File
@@ -14,10 +14,7 @@
package app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -33,50 +30,26 @@ import (
// 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) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := mcptypes.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)
}
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.CLIOverlay{
ID: sc.Key,
Command: sc.Key,
Skip: true,
}
}
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
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
overlay := resolveStdioOverlay(p, sc)
descriptor := mcptypes.ServerDescriptor{
func stdioServerDescriptorFromManifest(
p *plugin.Plugin,
sc plugin.StdioServerClient,
) (mcptypes.ServerDescriptor, bool) {
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
}, true
}
func registerResolvedStdioServer(
p *plugin.Plugin,
sc plugin.StdioServerClient,
descriptor mcptypes.ServerDescriptor,
) {
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
slog.Debug("plugin: stdio server registered from manifest",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides))
"toolOverrides", len(descriptor.CLI.ToolOverrides))
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
descriptor, ok := stdioServerDescriptorFromManifest(p, sc)
if !ok {
return mcptypes.ServerDescriptor{}
}
registerResolvedStdioServer(p, sc, descriptor)
return descriptor
}
+330 -34
View File
@@ -23,6 +23,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@@ -69,7 +70,8 @@ var (
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
rootPluginStdioClients = (*plugin.Plugin).StdioClients
rootRegisterPluginHTTPServer = registerPluginHTTPServer
rootRegisterStdioManifest = registerStdioServerFromManifest
rootPluginStdioDescriptor = stdioServerDescriptorFromManifest
rootRegisterResolvedStdioServer = registerResolvedStdioServer
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
@@ -306,13 +308,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return NewRootCommandWithEngine(rootCtx, nil)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used by Schema generation and command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed embedded Schema.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
// optional pipeline engine for input correction. When engine is nil,
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -368,11 +385,16 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
schemaCmd := newSchemaCommand(loader)
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
// The legacy dynamic MCP surface remains disabled, but reviewed static MCP
// helpers registered below are part of the public CLI and Schema surface.
mcpCmd.Hidden = false
mcpCmd.Short = "管理 MCP 服务连接信息"
mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。"
// Wrap the caller so every MCP tool call's shape is recorded to the local
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
// `dws shortcut stats` and future high-frequency shortcut distillation.
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
@@ -396,16 +418,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := rootLoadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
@@ -414,6 +429,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
fn(root, caller)
deduplicateCommands(root)
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
// present, so endpoint and Cobra conflict checks see PAT and edition
// commands as well as the open-source base.
pluginCmds := rootLoadPlugins(root, engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
@@ -631,12 +655,17 @@ var reservedCommands = map[string]bool{
"schema": true, "mcp": true, "help": true,
}
var replaceablePluginFallbacks = map[string]bool{
"conference": true,
}
// addPluginCommandsSafe registers plugin commands with conflict detection.
//
// Rules:
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
// - Plugin vs plugin (same name) → reject later one, warn
// - Plugin vs Market dynamic command → allow, plugin wins
// - Plugin vs hidden compatibility fallback → allow, plugin wins
// - Plugin vs visible distribution command → reject, warn
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
// Build index of existing commands before plugin registration.
existing := make(map[string]bool)
@@ -664,17 +693,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
}
pluginSeen[name] = true
// Rule 3: plugin vs Market — plugin wins, remove the old one.
// An alias must not bypass the same protections applied to primary
// plugin command names or shadow another root command.
filteredAliases := make([]string, 0, len(cmd.Aliases))
for _, rawAlias := range cmd.Aliases {
alias := strings.TrimSpace(rawAlias)
if alias == "" || alias == name || reservedCommands[alias] ||
existing[alias] || pluginSeen[alias] {
if alias != "" {
slog.Warn("plugin: command alias conflicts with an existing command, skipping",
"command", name, "alias", alias)
}
continue
}
pluginSeen[alias] = true
filteredAliases = append(filteredAliases, alias)
}
cmd.Aliases = filteredAliases
// Rule 3: an installed plugin may replace a hidden compatibility
// fallback (for example conference), but never a visible distribution
// command that participates in the reviewed base interface.
if existing[name] {
for _, old := range root.Commands() {
if old.Name() == name {
if !old.Hidden || !replaceablePluginFallbacks[name] ||
cmdutil.IsPluginSourced(old) {
slog.Warn("plugin: command conflicts with a visible distribution command, skipping",
"command", name)
cmd = nil
break
}
root.RemoveCommand(old)
slog.Debug("plugin: overriding Market command",
slog.Debug("plugin: overriding hidden compatibility command",
"command", name)
break
}
}
}
if cmd == nil {
continue
}
root.AddCommand(cmd)
}
@@ -811,7 +870,21 @@ func CloseFileLogger() {
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
// skills. It deliberately does not initialize MCP transports or call
// tools/list while constructing the command tree.
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
type pluginServerCandidate struct {
owner *plugin.Plugin
order int
descriptor mcptypes.ServerDescriptor
stdioClient *plugin.StdioServerClient
}
type pluginIdentityOwner struct {
plugin *plugin.Plugin
serverKey string
rootName string
shareable bool
}
func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
pluginLoader := plugin.NewLoader(RawVersion())
// 0a. Inject plugin config values from settings.json as environment
@@ -838,25 +911,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := rootPluginLoadDev(pluginLoader)
sortPluginsForRegistration(userPlugins)
sortPluginsForRegistration(devPlugins)
allPlugins := append(userPlugins, devPlugins...)
descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins))
// 3. Register HTTP descriptors and authentication from the manifest.
for _, p := range allPlugins {
for _, srv := range rootPluginDescriptors(p) {
rootRegisterPluginHTTPServer(srv)
// 3. Resolve every descriptor once, then choose identity winners before
// mutating endpoint, auth, or stdio-client registries. This keeps the
// visible command and its transport owned by the same plugin.
candidates := collectPluginServerCandidates(allPlugins, userCtx)
accepted := selectPluginServerCandidates(root, candidates)
for _, candidate := range accepted {
if candidate.stdioClient != nil {
rootRegisterResolvedStdioServer(
candidate.owner,
*candidate.stdioClient,
candidate.descriptor,
)
} else {
rootRegisterPluginHTTPServer(candidate.descriptor)
}
descriptorsByPlugin[candidate.owner] = append(
descriptorsByPlugin[candidate.owner],
candidate.descriptor,
)
}
// 4. Register stdio descriptors and unstarted clients. The subprocess is
// started and initialized only when a command is actually executed.
for _, p := range allPlugins {
for _, sc := range rootPluginStdioClients(p, userCtx) {
rootRegisterStdioManifest(p, sc)
}
}
// 5. Register plugin hooks into pipeline engine
// 4. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
hooksCfg, err := rootPluginLoadHooks(p)
@@ -874,7 +956,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
}
// 7. Sync plugin skills to agent directories
// 5. Sync plugin skills to agent directories
rootPluginSyncSkills(allPlugins)
if len(allPlugins) > 0 {
@@ -884,11 +966,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
)
}
return nil
var pluginCommands []*cobra.Command
for _, p := range allPlugins {
// Build each plugin independently. addPluginCommandsSafe deliberately
// resolves cross-plugin root conflicts with first-plugin-wins semantics.
pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...)
}
return pluginCommands
}
func sortPluginsForRegistration(plugins []*plugin.Plugin) {
sort.SliceStable(plugins, func(i, j int) bool {
left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root)
right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root)
return left < right
})
}
func collectPluginServerCandidates(
plugins []*plugin.Plugin,
userCtx *plugin.UserContext,
) []pluginServerCandidate {
var candidates []pluginServerCandidate
for order, owner := range plugins {
for _, descriptor := range rootPluginDescriptors(owner) {
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
})
}
for _, stdioClient := range rootPluginStdioClients(owner, userCtx) {
descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient)
if !ok {
continue
}
clientCopy := stdioClient
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
stdioClient: &clientCopy,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].order != candidates[j].order {
return candidates[i].order < candidates[j].order
}
left := strings.TrimSpace(candidates[i].descriptor.Key)
right := strings.TrimSpace(candidates[j].descriptor.Key)
if left != right {
return left < right
}
return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil
})
return candidates
}
func selectPluginServerCandidates(
root *cobra.Command,
candidates []pluginServerCandidate,
) []pluginServerCandidate {
distributionProducts := DirectRuntimeProductIDs()
owners := make(map[string]pluginIdentityOwner)
for identity := range distributionProducts {
if replaceablePluginFallbacks[identity] {
continue
}
owners[identity] = pluginIdentityOwner{serverKey: "distribution"}
}
accepted := make([]pluginServerCandidate, 0, len(candidates))
for _, candidate := range candidates {
descriptor := candidate.descriptor
if descriptor.CLI.Skip {
continue
}
if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" {
slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"field", reason)
continue
}
if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) {
continue
}
claims := pluginDescriptorIdentityClaims(descriptor)
conflict := ""
for identity, shareable := range claims {
existing, exists := owners[identity]
if !exists {
continue
}
rootName := pluginDescriptorRootName(descriptor)
if shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
conflict = identity
break
}
if conflict != "" {
slog.Warn("plugin: descriptor identity already owned, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"identity", conflict)
continue
}
rootName := pluginDescriptorRootName(descriptor)
for identity, shareable := range claims {
if existing, exists := owners[identity]; exists &&
shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
owners[identity] = pluginIdentityOwner{
plugin: candidate.owner,
serverKey: descriptor.Key,
rootName: rootName,
shareable: shareable,
}
}
accepted = append(accepted, candidate)
}
return accepted
}
func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool {
claims := make(map[string]bool)
canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key)
if canonicalID != "" {
claims[canonicalID] = false
}
for _, identity := range append(
[]string{pluginDescriptorRootName(descriptor)},
descriptor.CLI.Aliases...,
) {
identity = strings.TrimSpace(identity)
if identity == "" {
continue
}
if _, exists := claims[identity]; !exists {
claims[identity] = true
}
}
return claims
}
func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string {
return firstNonEmptyPluginString(
descriptor.CLI.Command,
descriptor.CLI.ID,
descriptor.Key,
)
}
func pluginDescriptorConflictsWithDistribution(
root *cobra.Command,
descriptor mcptypes.ServerDescriptor,
distributionProducts map[string]bool,
) bool {
candidates := append(
[]string{
firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key),
pluginDescriptorRootName(descriptor),
},
descriptor.CLI.Aliases...,
)
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] {
// The distribution ships only a hidden compatibility fallback for
// this name; plugins may claim it and the later command merge in
// addPluginCommandsSafe still rejects visible non-fallback owners.
continue
}
if reservedCommands[candidate] ||
distributionProducts[candidate] ||
distributionRootOwns(root, candidate) {
slog.Warn("plugin: descriptor conflicts with a distribution command, skipping",
"plugin", descriptor.DisplayName,
"server", descriptor.Key,
"identity", candidate)
return true
}
}
return false
}
func distributionRootOwns(root *cobra.Command, name string) bool {
if root == nil {
return false
}
for _, command := range root.Commands() {
if cmdutil.IsPluginSourced(command) {
continue
}
if command.Name() == name {
if command.Hidden && replaceablePluginFallbacks[name] {
return false
}
return true
}
for _, alias := range command.Aliases {
if strings.TrimSpace(alias) == name {
return true
}
}
}
return false
}
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
@@ -917,10 +1216,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
}
productID := strings.TrimSpace(srv.CLI.ID)
if productID == "" {
productID = srv.Key
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
+33 -8
View File
@@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
})
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
}
preRunCalled := false
@@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
oldDescriptors := rootPluginDescriptors
oldStdioClients := rootPluginStdioClients
oldHTTP := rootRegisterPluginHTTPServer
oldStdio := rootRegisterStdioManifest
oldStdioDescriptor := rootPluginStdioDescriptor
oldStdioRegister := rootRegisterResolvedStdioServer
oldHooks := rootPluginLoadHooks
oldSync := rootPluginSyncSkills
oldToken := rootAuthLoadTokenData
@@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
rootPluginDescriptors = oldDescriptors
rootPluginStdioClients = oldStdioClients
rootRegisterPluginHTTPServer = oldHTTP
rootRegisterStdioManifest = oldStdio
rootPluginStdioDescriptor = oldStdioDescriptor
rootRegisterResolvedStdioServer = oldStdioRegister
rootPluginLoadHooks = oldHooks
rootPluginSyncSkills = oldSync
rootAuthLoadTokenData = oldToken
@@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
if p == p1 {
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{
Key: "http", Endpoint: "https://example.test",
CLI: mcptypes.CLIOverlay{
ID: "http", Command: "one-http",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"ping": {CLIName: "ping"},
},
},
}}
}
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}}
}
client := transport.NewStdioClient("ignored", nil, nil)
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
@@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
httpCount := 0
stdioCount := 0
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
return mcptypes.ServerDescriptor{
Key: "local",
CLI: mcptypes.CLIOverlay{
ID: "local", Command: "one-stdio",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"pong": {CLIName: "pong"},
},
},
}, true
}
rootRegisterResolvedStdioServer = func(
*plugin.Plugin,
plugin.StdioServerClient,
mcptypes.ServerDescriptor,
) {
stdioCount++
return mcptypes.ServerDescriptor{}
}
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
switch p {
@@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
synced := false
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{})
if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" {
t.Fatalf("loaded plugin commands = %#v", got)
}
if httpCount != 3 || stdioCount != 1 || !synced {
+118 -1
View File
@@ -15,11 +15,14 @@ package app
import (
"bytes"
stderrors "errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -75,7 +78,14 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
}
}
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
mediaGroup := mustFindCommand(t, root, "chat", "media")
if mediaGroup.Deprecated == "" || mediaGroup.Hidden || !mediaGroup.Runnable() {
t.Fatalf("chat media compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaGroup.Deprecated, mediaGroup.Hidden, mediaGroup.Runnable())
}
mediaUpload := mustFindCommand(t, mediaGroup, "upload")
if mediaUpload.Deprecated == "" || mediaUpload.Hidden || !mediaUpload.Runnable() {
t.Fatalf("chat media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaUpload.Deprecated, mediaUpload.Hidden, mediaUpload.Runnable())
}
for _, flag := range []string{"file", "type"} {
if mediaUpload.Flags().Lookup(flag) == nil {
t.Fatalf("chat media upload missing --%s", flag)
@@ -88,6 +98,113 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
mustFindCommand(t, root, "conference", "meeting", "reserve")
}
func TestChatHelpAndSchemaHideRetiredMediaUpload(t *testing.T) {
for _, args := range [][]string{
{"chat", "--help"},
{"chat", "media", "--help"},
} {
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("dws %s: %v\n%s", strings.Join(args, " "), err, output.String())
}
for _, line := range strings.Split(output.String(), "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && (fields[0] == "media" || fields[0] == "upload") {
t.Fatalf("dws %s exposes retired command in Help line %q:\n%s", strings.Join(args, " "), line, output.String())
}
}
}
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs([]string{"schema", "--cli-path", "chat media upload", "--format", "json"})
err := root.Execute()
if err == nil {
t.Fatalf("retired chat media upload remains queryable from Schema:\n%s", output.String())
}
if !strings.Contains(err.Error(), "unknown runtime schema path") {
t.Fatalf("retired chat media upload Schema error = %v, want unknown path", err)
}
}
func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *testing.T) {
for _, key := range []string{"DWS_CLIENT_ID", "DWS_CLIENT_SECRET"} {
value, existed := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv(key, value)
return
}
_ = os.Unsetenv(key)
})
if _, exists := os.LookupEnv(key); exists {
t.Fatalf("%s is still set", key)
}
}
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
filePath := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(filePath, []byte("image"), 0o600); err != nil {
t.Fatalf("write image fixture: %v", err)
}
commandArgs := []string{
"chat", "media", "upload",
"--file", filePath,
"--type", "image",
}
previousArgs := os.Args
os.Args = append([]string{"dws"}, commandArgs...)
t.Cleanup(func() { os.Args = previousArgs })
root := NewRootCommand()
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(commandArgs)
err := root.Execute()
if err == nil {
t.Fatalf("chat media upload succeeded without app credentials:\n%s", output.String())
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("chat media upload error type = %T, want *errors.Error: %v", err, err)
}
if typed.Category != apperrors.CategoryValidation {
t.Fatalf("chat media upload category = %q, want %q", typed.Category, apperrors.CategoryValidation)
}
if exitCode := apperrors.ExitCode(err); exitCode != 3 {
t.Fatalf("chat media upload exit code = %d, want 3", exitCode)
}
got := output.String() + "\n" + err.Error()
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{
"DWS_CLIENT_ID",
"DWS_CLIENT_SECRET",
"缺少应用凭证",
"AppSecret",
"clientSecret",
} {
if strings.Contains(got, forbidden) {
t.Fatalf("chat media upload returned credential error %q:\n%s", forbidden, got)
}
}
}
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
label := mustFindCommand(t, root, "contact", "label")
+37 -3
View File
@@ -168,6 +168,7 @@ var (
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
runnerStdioListTools = (*transport.StdioClient).ListTools
runnerStdioCallTool = (*transport.StdioClient).CallTool
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
@@ -485,7 +486,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
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) {
return r.executeStdioInvocation(ctx, invocation)
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
// Constructing the Cobra tree is also used for help, schema, and command
@@ -766,6 +767,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// subprocess instead of the HTTP transport. This is used for plugin stdio
// servers whose endpoints use the stdio:// scheme.
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
) (executor.Result, error) {
if invocation.DryRun {
return executor.Result{
Invocation: invocation,
@@ -778,10 +787,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
client, ok := LookupStdioClient(invocation.CanonicalProduct)
lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/")
if lookupKey == "" {
lookupKey = invocation.CanonicalProduct
}
client, ok := LookupStdioClient(lookupKey)
if !ok {
return executor.Result{}, apperrors.NewInternal(
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
fmt.Sprintf("stdio client not found for %q", lookupKey))
}
callCtx := ctx
@@ -798,6 +811,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
)
}
tools, err := runnerStdioListTools(client, callCtx)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio tools/list failed: %v", err),
apperrors.WithOperation("tools/list"),
apperrors.WithReason("stdio_tools_list_error"),
)
}
schema, ok := pluginToolInputSchema(tools, invocation.Tool)
if !ok {
return executor.Result{}, apperrors.NewValidation(
fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool),
apperrors.WithReason("plugin_tool_not_found"),
)
}
normalizedParams, err := normalizePluginInputParams(invocation.Params, schema)
if err != nil {
return executor.Result{}, err
}
invocation.Params = normalizedParams
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
+20
View File
@@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
oldStdioInit := runnerStdioEnsureInitialized
oldStdioList := runnerStdioListTools
oldStdioCall := runnerStdioCallTool
oldEdition := edition.Get()
t.Cleanup(func() {
runnerStdioEnsureInitialized = oldStdioInit
runnerStdioListTools = oldStdioList
runnerStdioCallTool = oldStdioCall
edition.Override(oldEdition)
StopAllStdioClients()
@@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
t.Fatalf("stdio initialize error = %v", err)
}
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "tool",
InputSchema: map[string]any{"type": "object"},
}},
}, nil
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
@@ -327,6 +337,16 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio success = %#v, %v", got, err)
}
RegisterStdioClient("plugin/server-key", client)
overlayIDInvocation := inv
overlayIDInvocation.CanonicalProduct = "overlay-id"
if got, err := r.executeInvocation(
context.Background(),
"stdio://plugin/server-key",
overlayIDInvocation,
); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err)
}
r.globalFlags.Token = " explicit "
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if err != nil {
t.Fatal(err)
}
root := NewRootCommand()
root := NewSchemaSourceRootCommand()
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
t.Fatal(err)
}
+11
View File
@@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string {
}
return out
}
// normalizeSchemaQueryCLIPath accepts the historical query spellings while
// keeping authored Registry CLI paths strict and space-separated. Canonical
// identity lookup still runs before this compatibility normalization.
func normalizeSchemaQueryCLIPath(path string) string {
parts := splitSchemaPathTokens(strings.TrimSpace(path))
if len(parts) > 0 && parts[0] == "dws" {
parts = parts[1:]
}
return strings.Join(parts, " ")
}
+15 -15
View File
@@ -2,7 +2,7 @@
"product_id": "event",
"tools": {
"event consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -13,19 +13,19 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -105,8 +105,8 @@
},
"examples": {
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -115,8 +115,8 @@
"candidates": [
{
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -538,7 +538,7 @@
]
},
"event schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -549,18 +549,18 @@
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"field_provenance": {
"agent_summary": {
"value": "查询指定个人事件码的 payload 字段结构",
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "查询指定个人事件码的 payload 字段结构",
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -640,7 +640,7 @@
},
"examples": {
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -649,7 +649,7 @@
"candidates": [
{
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
+81 -12
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec",
"surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5",
"coverage": {
"surface_products": 22,
"products_with_metadata": 22,
"surface_tools": 572,
"tools_with_metadata": 572,
"tools_with_agent_summary": 572,
"tools_with_use_when": 572,
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 120,
"surface_products": 23,
"products_with_metadata": 23,
"surface_tools": 573,
"tools_with_metadata": 573,
"tools_with_agent_summary": 573,
"tools_with_use_when": 573,
"tools_with_avoid_when": 573,
"tools_with_examples": 573,
"tools_with_interface_mode": 573,
"unmatched_skill_tools": 119,
"unreviewed_skill_tools": 7
},
"products": {
@@ -1089,6 +1089,74 @@
"查收、搜索、阅读、回复、发送或整理邮件"
]
},
"mcp": {
"agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息",
"agent_summary_source": "dws-agent-selection/mcp",
"avoid_when": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
],
"field_provenance": {
"agent_summary": {
"value": "解析和管理当前身份可用的 MCP 服务连接信息",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "解析和管理当前身份可用的 MCP 服务连接信息",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"avoid_when": {
"value": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"use_when": {
"value": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
}
},
"source_refs": [
"CommandRegistry:product=mcp",
"cobra-help:dws mcp --help",
"internal/cli/schema_command_registry.json#mcp",
"internal/cli/schema_hints/selection/mcp.json"
],
"use_when": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
]
},
"minutes": {
"agent_summary": "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
"agent_summary_source": "dws-agent-selection/minutes",
@@ -1605,6 +1673,7 @@
"event",
"live",
"mail",
"mcp",
"minutes",
"oa",
"pat",
+267
View File
@@ -0,0 +1,267 @@
{
"product_id": "mcp",
"tools": {
"mcp url get": {
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws mcp url get 10043 --format json"
],
"field_provenance": {
"agent_summary": {
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"avoid_when": {
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"examples": {
"value": [
"dws mcp url get 10043 --format json"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"dws mcp url get 10043 --format json"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
]
},
"use_when": {
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"reviewed": true,
"risk": "medium",
"source_refs": [
"cobra-help:dws mcp url get --help",
"internal/app/mcp_url_command.go",
"internal/cli/schema_command_registry.json#mcp.url_get",
"internal/cli/schema_hints/metadata/mcp.json",
"internal/cli/schema_hints/selection/mcp.json",
"pkg/edition/default.go#openSupplementServers"
],
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
}
}
+77 -118
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_files": 150,
"hint_files": 46,
"hint_products": 39,
"hint_tools": 1296,
"source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec",
"surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5",
"source_files": 152,
"hint_files": 48,
"hint_products": 40,
"hint_tools": 1298,
"interface_metadata": {
"source": "mcp-tools-list+cli-registry",
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
@@ -27,16 +27,16 @@
]
},
"coverage": {
"surface_products": 22,
"products_with_metadata": 22,
"surface_tools": 572,
"tools_with_metadata": 572,
"tools_with_agent_summary": 572,
"tools_with_use_when": 572,
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 120,
"surface_products": 23,
"products_with_metadata": 23,
"surface_tools": 573,
"tools_with_metadata": 573,
"tools_with_agent_summary": 573,
"tools_with_use_when": 573,
"tools_with_avoid_when": 573,
"tools_with_examples": 573,
"tools_with_interface_mode": 573,
"unmatched_skill_tools": 119,
"unreviewed_skill_tools": 7
},
"source_products": [
@@ -60,6 +60,7 @@
"generate-topic-report",
"live",
"mail",
"mcp",
"meeting-followup",
"minutes",
"oa",
@@ -1504,38 +1505,10 @@
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "chat media upload",
"source": "skills/mono/references/products/chat.md",
"line": 610,
"candidates": [
"chat bot find",
"chat bot search",
"chat category create-smart"
],
"review": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "chat media upload",
"source": "skills/mono/references/products/chat.md",
"line": 611,
"candidates": [
"chat bot find",
"chat bot search",
"chat category create-smart"
],
"review": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "chat message list-emotion-replies",
"source": "skills/mono/references/products/chat.md",
"line": 1086,
"line": 1061,
"candidates": [
"chat message add-emoji",
"chat message add-favorite",
@@ -1549,7 +1522,7 @@
{
"tool_path": "chat category create",
"source": "skills/mono/references/products/chat.md",
"line": 1441,
"line": 1416,
"candidates": [
"chat group create",
"chat category create-smart",
@@ -1563,7 +1536,7 @@
{
"tool_path": "chat category delete",
"source": "skills/mono/references/products/chat.md",
"line": 1466,
"line": 1441,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -1577,7 +1550,7 @@
{
"tool_path": "chat category rename",
"source": "skills/mono/references/products/chat.md",
"line": 1477,
"line": 1452,
"candidates": [
"chat group rename",
"chat category create-smart",
@@ -1591,7 +1564,7 @@
{
"tool_path": "chat category add-conv",
"source": "skills/mono/references/products/chat.md",
"line": 1489,
"line": 1464,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -1605,7 +1578,7 @@
{
"tool_path": "chat category remove-conv",
"source": "skills/mono/references/products/chat.md",
"line": 1502,
"line": 1477,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -1619,7 +1592,7 @@
{
"tool_path": "chat text translate",
"source": "skills/mono/references/products/chat.md",
"line": 1517,
"line": 1492,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1633,7 +1606,7 @@
{
"tool_path": "chat text translate",
"source": "skills/mono/references/products/chat.md",
"line": 1518,
"line": 1493,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1647,7 +1620,7 @@
{
"tool_path": "chat text translate",
"source": "skills/mono/references/products/chat.md",
"line": 1519,
"line": 1494,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1661,7 +1634,7 @@
{
"tool_path": "chat hide",
"source": "skills/mono/references/products/chat.md",
"line": 1556,
"line": 1531,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1675,7 +1648,7 @@
{
"tool_path": "chat hide",
"source": "skills/mono/references/products/chat.md",
"line": 1557,
"line": 1532,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1689,7 +1662,7 @@
{
"tool_path": "chat mute-at-all",
"source": "skills/mono/references/products/chat.md",
"line": 1579,
"line": 1554,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1703,7 +1676,7 @@
{
"tool_path": "chat mute-at-all",
"source": "skills/mono/references/products/chat.md",
"line": 1580,
"line": 1555,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1717,7 +1690,7 @@
{
"tool_path": "chat mute-red-envelope",
"source": "skills/mono/references/products/chat.md",
"line": 1602,
"line": 1577,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1731,7 +1704,7 @@
{
"tool_path": "chat mute-red-envelope",
"source": "skills/mono/references/products/chat.md",
"line": 1603,
"line": 1578,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1745,7 +1718,7 @@
{
"tool_path": "chat mark-unread",
"source": "skills/mono/references/products/chat.md",
"line": 1623,
"line": 1598,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1759,7 +1732,7 @@
{
"tool_path": "chat mark-unread",
"source": "skills/mono/references/products/chat.md",
"line": 1624,
"line": 1599,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1773,7 +1746,7 @@
{
"tool_path": "chat clear-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1642,
"line": 1617,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1787,7 +1760,7 @@
{
"tool_path": "chat clear-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1643,
"line": 1618,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1801,7 +1774,7 @@
{
"tool_path": "chat clear-all-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1659,
"line": 1634,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1815,7 +1788,7 @@
{
"tool_path": "chat clear-all-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1661,
"line": 1636,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1829,7 +1802,7 @@
{
"tool_path": "chat list-all-conversations",
"source": "skills/mono/references/products/chat.md",
"line": 1675,
"line": 1650,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1843,7 +1816,7 @@
{
"tool_path": "chat list-all-conversations",
"source": "skills/mono/references/products/chat.md",
"line": 1676,
"line": 1651,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1857,7 +1830,7 @@
{
"tool_path": "chat list-all-conversations",
"source": "skills/mono/references/products/chat.md",
"line": 1677,
"line": 1652,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1871,7 +1844,7 @@
{
"tool_path": "chat list-all-conversations",
"source": "skills/mono/references/products/chat.md",
"line": 1678,
"line": 1653,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1885,7 +1858,7 @@
{
"tool_path": "chat clear-messages",
"source": "skills/mono/references/products/chat.md",
"line": 1698,
"line": 1673,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1899,7 +1872,7 @@
{
"tool_path": "chat clear-messages",
"source": "skills/mono/references/products/chat.md",
"line": 1699,
"line": 1674,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1913,7 +1886,7 @@
{
"tool_path": "chat mark-read",
"source": "skills/mono/references/products/chat.md",
"line": 1717,
"line": 1692,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1927,7 +1900,7 @@
{
"tool_path": "chat mark-read",
"source": "skills/mono/references/products/chat.md",
"line": 1718,
"line": 1693,
"candidates": [
"chat bot find",
"chat bot search",
@@ -1941,7 +1914,7 @@
{
"tool_path": "chat group list-join-validations",
"source": "skills/mono/references/products/chat.md",
"line": 1761,
"line": 1736,
"candidates": [
"chat group bots",
"chat group create",
@@ -1955,7 +1928,7 @@
{
"tool_path": "chat group list-join-validations",
"source": "skills/mono/references/products/chat.md",
"line": 1762,
"line": 1737,
"candidates": [
"chat group bots",
"chat group create",
@@ -1969,7 +1942,7 @@
{
"tool_path": "chat group list-join-validations",
"source": "skills/mono/references/products/chat.md",
"line": 1763,
"line": 1738,
"candidates": [
"chat group bots",
"chat group create",
@@ -1983,7 +1956,7 @@
{
"tool_path": "chat group audit-join-validation",
"source": "skills/mono/references/products/chat.md",
"line": 1783,
"line": 1758,
"candidates": [
"chat group bots",
"chat group create",
@@ -1997,7 +1970,7 @@
{
"tool_path": "chat group audit-join-validation",
"source": "skills/mono/references/products/chat.md",
"line": 1784,
"line": 1759,
"candidates": [
"chat group bots",
"chat group create",
@@ -2011,7 +1984,7 @@
{
"tool_path": "chat group members",
"source": "skills/mono/references/products/chat.md",
"line": 1816,
"line": 1791,
"candidates": [
"chat group members add",
"chat group members add-bot",
@@ -2025,7 +1998,7 @@
{
"tool_path": "chat group update-alias",
"source": "skills/mono/references/products/chat.md",
"line": 1821,
"line": 1796,
"candidates": [
"chat group bots",
"chat group create",
@@ -2039,7 +2012,7 @@
{
"tool_path": "chat group update-nick",
"source": "skills/mono/references/products/chat.md",
"line": 1822,
"line": 1797,
"candidates": [
"chat group bots",
"chat group create",
@@ -2053,7 +2026,7 @@
{
"tool_path": "chat group members list-by-ids",
"source": "skills/mono/references/products/chat.md",
"line": 1823,
"line": 1798,
"candidates": [
"chat message list-by-ids",
"chat group members add",
@@ -2067,7 +2040,7 @@
{
"tool_path": "chat category create",
"source": "skills/mono/references/products/chat.md",
"line": 1847,
"line": 1822,
"candidates": [
"chat group create",
"chat category create-smart",
@@ -2081,7 +2054,7 @@
{
"tool_path": "chat category delete",
"source": "skills/mono/references/products/chat.md",
"line": 1849,
"line": 1824,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -2095,7 +2068,7 @@
{
"tool_path": "chat category rename",
"source": "skills/mono/references/products/chat.md",
"line": 1850,
"line": 1825,
"candidates": [
"chat group rename",
"chat category create-smart",
@@ -2109,7 +2082,7 @@
{
"tool_path": "chat category add-conv",
"source": "skills/mono/references/products/chat.md",
"line": 1851,
"line": 1826,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -2123,7 +2096,7 @@
{
"tool_path": "chat category remove-conv",
"source": "skills/mono/references/products/chat.md",
"line": 1852,
"line": 1827,
"candidates": [
"chat category create-smart",
"chat category list",
@@ -2137,7 +2110,7 @@
{
"tool_path": "chat group share-invite",
"source": "skills/mono/references/products/chat.md",
"line": 1863,
"line": 1838,
"candidates": [
"chat group bots",
"chat group create",
@@ -2151,7 +2124,7 @@
{
"tool_path": "chat group notice create/edit/get/list",
"source": "skills/mono/references/products/chat.md",
"line": 1864,
"line": 1839,
"candidates": [
"chat group bots",
"chat group create",
@@ -2165,7 +2138,7 @@
{
"tool_path": "chat message list-emotion-replies",
"source": "skills/mono/references/products/chat.md",
"line": 1866,
"line": 1841,
"candidates": [
"chat message add-emoji",
"chat message add-favorite",
@@ -2179,7 +2152,7 @@
{
"tool_path": "chat hide",
"source": "skills/mono/references/products/chat.md",
"line": 1873,
"line": 1848,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2193,7 +2166,7 @@
{
"tool_path": "chat mute-at-all",
"source": "skills/mono/references/products/chat.md",
"line": 1874,
"line": 1849,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2207,7 +2180,7 @@
{
"tool_path": "chat mute-at-all",
"source": "skills/mono/references/products/chat.md",
"line": 1875,
"line": 1850,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2221,7 +2194,7 @@
{
"tool_path": "chat mute-red-envelope",
"source": "skills/mono/references/products/chat.md",
"line": 1876,
"line": 1851,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2235,7 +2208,7 @@
{
"tool_path": "chat mute-red-envelope",
"source": "skills/mono/references/products/chat.md",
"line": 1877,
"line": 1852,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2249,7 +2222,7 @@
{
"tool_path": "chat mark-unread",
"source": "skills/mono/references/products/chat.md",
"line": 1878,
"line": 1853,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2263,7 +2236,7 @@
{
"tool_path": "chat mark-read",
"source": "skills/mono/references/products/chat.md",
"line": 1879,
"line": 1854,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2277,7 +2250,7 @@
{
"tool_path": "chat clear-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1880,
"line": 1855,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2291,7 +2264,7 @@
{
"tool_path": "chat clear-all-red-point",
"source": "skills/mono/references/products/chat.md",
"line": 1881,
"line": 1856,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2305,7 +2278,7 @@
{
"tool_path": "chat list-all-conversations",
"source": "skills/mono/references/products/chat.md",
"line": 1882,
"line": 1857,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2319,7 +2292,7 @@
{
"tool_path": "chat clear-messages",
"source": "skills/mono/references/products/chat.md",
"line": 1883,
"line": 1858,
"candidates": [
"chat bot find",
"chat bot search",
@@ -2333,7 +2306,7 @@
{
"tool_path": "chat group list-join-validations",
"source": "skills/mono/references/products/chat.md",
"line": 1885,
"line": 1860,
"candidates": [
"chat group bots",
"chat group create",
@@ -2347,7 +2320,7 @@
{
"tool_path": "chat group audit-join-validation",
"source": "skills/mono/references/products/chat.md",
"line": 1886,
"line": 1861,
"candidates": [
"chat group bots",
"chat group create",
@@ -2358,24 +2331,10 @@
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "chat media upload",
"source": "skills/mono/references/products/chat.md",
"line": 1893,
"candidates": [
"chat bot find",
"chat bot search",
"chat category create-smart"
],
"review": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "chat text translate",
"source": "skills/mono/references/products/chat.md",
"line": 1905,
"line": 1881,
"candidates": [
"chat bot find",
"chat bot search",
+1 -1
View File
@@ -290,7 +290,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
return payload, nil
}
raw := strings.TrimSpace(args[0])
if tool, ok := loaded.Index.Resolve(raw); ok {
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
return schemaToolForResolvedPath(tool, raw).ToPayload()
}
tokens := splitSchemaPathTokens(raw)
+652 -48
View File
@@ -1,21 +1,21 @@
{
"version": 1,
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_hash": "sha256:360114d03cc3e8dc6cef30c6e617cc40f88e5577c29dd5b5946fe1cde91eb9de",
"surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5",
"catalog": {
"agent_metadata": {
"products_with_metadata": 22,
"products_with_metadata": 23,
"source": "embedded-skill-metadata",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"surface_products": 22,
"surface_tools": 572,
"tools_with_agent_summary": 572,
"tools_with_metadata": 572,
"unmatched_skill_tools": 120,
"source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec",
"surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5",
"surface_products": 23,
"surface_tools": 573,
"tools_with_agent_summary": 573,
"tools_with_metadata": 573,
"unmatched_skill_tools": 119,
"version": 1
},
"count": 22,
"count": 23,
"interface_metadata": {
"coverage": {
"aliased_tools": 178,
@@ -7660,7 +7660,7 @@
"cli_name": "send",
"cli_path": "chat message send",
"confirmation": "not_required",
"description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送",
"description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。",
"effect": "write",
"group": "message",
"idempotency": "unknown",
@@ -12178,7 +12178,7 @@
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -12189,7 +12189,7 @@
"cli_name": "consume",
"cli_path": "event consume",
"confirmation": "not_required",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"effect": "write",
"idempotency": "non_idempotent",
"interface_mode": "composite",
@@ -12234,7 +12234,7 @@
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -13476,6 +13476,110 @@
"查收、搜索、阅读、回复、发送或整理邮件"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:product=mcp",
"cobra-help:dws mcp --help",
"internal/cli/schema_command_registry.json#mcp",
"internal/cli/schema_hints/selection/mcp.json"
],
"agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息",
"agent_summary_source": "dws-agent-selection/mcp",
"avoid_when": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
],
"description": "管理 MCP 服务连接信息",
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "解析和管理当前身份可用的 MCP 服务连接信息"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "解析和管理当前身份可用的 MCP 服务连接信息"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
]
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
]
}
},
"id": "mcp",
"name": "管理 MCP 服务连接信息",
"runtime": true,
"tool_count": 1,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"canonical_path": "mcp.url_get",
"cli_name": "get",
"cli_path": "mcp url get",
"confirmation": "not_required",
"description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。",
"effect": "read",
"group": "url",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"name": "url_get",
"primary_cli_path": "mcp url get",
"reviewed": true,
"risk": "medium",
"title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
],
"use_when": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
@@ -19153,7 +19257,7 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 572
"tool_count": 573
},
"tools": {
"aisearch.enterprise_person_search": {
@@ -196483,7 +196587,7 @@
]
]
},
"description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送",
"description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。",
"display": "群聊 / 消息 / 机器人",
"effect": "write",
"effect_source": "agent-hint",
@@ -196600,7 +196704,7 @@
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送"
"value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。"
},
{
"precedence": "mcp_metadata",
@@ -196612,7 +196716,7 @@
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送"
"value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。"
},
"effect": {
"candidates": [
@@ -197176,7 +197280,7 @@
"type": "string"
},
"file-path": {
"description": "本地文件路径(msgType=file 时可直接上传发送)",
"description": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)",
"field_provenance": {
"description": {
"candidates": [
@@ -197184,7 +197288,7 @@
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "本地文件路径(msgType=file 时可直接上传发送)"
"value": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)"
},
{
"precedence": "default",
@@ -197196,7 +197300,7 @@
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "本地文件路径(msgType=file 时可直接上传发送)"
"value": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)"
},
"format": {
"candidates": [
@@ -197398,7 +197502,7 @@
"type": "string"
},
"media-id": {
"description": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)",
"description": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)",
"example": "@lADP_schema_smoke",
"field_provenance": {
"description": {
@@ -197407,7 +197511,7 @@
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)"
"value": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)"
},
{
"precedence": "default",
@@ -197419,7 +197523,7 @@
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)"
"value": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)"
},
"example": {
"candidates": [
@@ -197517,7 +197621,7 @@
"type": "string"
},
"msg-type": {
"description": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)",
"description": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)",
"enum": [
"image",
"file",
@@ -197531,7 +197635,7 @@
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)"
"value": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)"
},
{
"precedence": "mcp_metadata",
@@ -197549,7 +197653,7 @@
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)"
"value": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)"
},
"enum": {
"candidates": [
@@ -290130,7 +290234,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -290149,13 +290253,13 @@
]
]
},
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"display": "事件订阅 (DingTalk Stream 长连接)",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -290165,14 +290269,14 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
},
"availability": {
"candidates": [
@@ -290250,13 +290354,13 @@
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
},
"effect": {
"candidates": [
@@ -290282,8 +290386,8 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
]
}
],
@@ -290292,8 +290396,8 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
]
},
"idempotency": {
@@ -290444,7 +290548,7 @@
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
"is_alias": false,
"name": "consume",
"parameter_count": 27,
"parameter_count": 28,
"parameters": {
"compact": {
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
@@ -291124,6 +291228,90 @@
"required": false,
"type": "string"
},
"flatten": {
"description": "将个人事件 transport envelope 投影为稳定的顶层业务字段",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "flatten"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "flatten"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"property": "flatten",
"required": false,
"type": "boolean"
},
"force": {
"description": "仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)",
"field_provenance": {
@@ -293464,7 +293652,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -293480,7 +293668,7 @@
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"field_provenance": {
"agent_summary": {
@@ -293490,14 +293678,14 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "查询指定个人事件码的 payload 字段结构"
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "查询指定个人事件码的 payload 字段结构"
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
},
"availability": {
"candidates": [
@@ -293607,7 +293795,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
]
}
],
@@ -293616,7 +293804,7 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
]
},
"idempotency": {
@@ -293763,8 +293951,92 @@
"interface_reason": "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
"is_alias": false,
"name": "schema",
"parameter_count": 1,
"parameter_count": 2,
"parameters": {
"flatten": {
"description": "显示 --flatten 消费模式对应的顶层业务字段 schema",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "flatten"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "flatten"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"property": "flatten",
"required": false,
"type": "boolean"
},
"format": {
"default": "json",
"description": "输出格式: json",
@@ -319607,6 +319879,338 @@
"已知 templateId 并需要修改名称、主题、正文或收件人时"
]
},
"mcp.url_get": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws mcp url get --help",
"internal/app/mcp_url_command.go",
"internal/cli/schema_command_registry.json#mcp.url_get",
"internal/cli/schema_hints/metadata/mcp.json",
"internal/cli/schema_hints/selection/mcp.json",
"pkg/edition/default.go#openSupplementServers"
],
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"canonical_path": "mcp.url_get",
"cli_name": "get",
"cli_path": "mcp url get",
"confirmation": "not_required",
"description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。",
"display": "管理 MCP 服务连接信息",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws mcp url get 10043 --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
},
"interface_ref": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": null
}
],
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": false,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
},
"group": "url",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"is_alias": false,
"name": "url_get",
"parameter_count": 0,
"parameters": {},
"path": "mcp.url_get",
"positionals": [
{
"description": "钉钉 MCP 市场中的 mcpId",
"index": 0,
"name": "mcp_id",
"required": true,
"type": "string"
}
],
"primary_cli_path": "mcp url get",
"product_id": "mcp",
"reviewed": true,
"risk": "medium",
"source": "reviewed_command_registry",
"title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
},
"minutes.add_member_permission": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
@@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T
}
}
func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) {
loaded := embeddedSchemaCatalog()
for _, path := range []string{
"dev app list",
"dev.app.list",
"dev/app/list",
} {
payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if err != nil {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err)
}
if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app")
}
}
}
type catalogHookSnapshot struct {
parameterBindings func(BoundCommandRegistry, SchemaRegistry) error
dryRun func(SchemaRegistry) error
@@ -321,7 +321,6 @@
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat media upload",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
@@ -1700,6 +1700,15 @@
}
]
},
{
"id": "mcp",
"tools": [
{
"canonical_path": "mcp.url_get",
"cli_path": "mcp url get"
}
]
},
{
"id": "minutes",
"tools": [
+16
View File
@@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) {
return i.registry.Products[location.product].Tools[location.tool], true
}
// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at
// the user-facing query boundary. Resolve remains strict because Registry
// validation uses it to detect missing canonical identities without falling
// through to a similarly spelled CLI path.
func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) {
if tool, ok := i.Resolve(path); ok {
return tool, true
}
canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)]
if !ok {
return ToolSpec{}, false
}
location := i.byCanonical[canonical]
return i.registry.Products[location.product].Tools[location.tool], true
}
// CanonicalPaths returns the complete tool identity set in stable order.
func (i SchemaIndex) CanonicalPaths() []string {
paths := make([]string, 0, len(i.byCanonical))
@@ -514,6 +514,19 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) {
t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
for _, path := range []string{
"calendar.attendee.delete",
"calendar/attendee/delete",
"dws.calendar.attendee.delete",
} {
resolved, ok := index.ResolveQuery(path)
if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" {
t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok {
t.Fatal("unknown dotted CLI path unexpectedly resolved")
}
if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) {
t.Fatalf("CanonicalPaths() = %#v", got)
}
@@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) {
}
}
func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{
Canonical: "sample.run",
CLIPath: "sample category run",
Aliases: []string{"sample legacy execute"},
})
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
loaded, err := decodeSchemaCatalogSnapshot(encoded)
if err != nil {
t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err)
}
canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"})
if err != nil {
t.Fatalf("canonical query: %v", err)
}
for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} {
alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if aliasErr != nil {
t.Fatalf("alias query %q: %v", path, aliasErr)
}
if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" {
t.Fatalf("alias projection for %q: %s", path, problem)
}
}
}
func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"})
snapshot.SurfaceHash = "sha256:reviewed-command-registry"
@@ -16,7 +16,7 @@ import (
// Go's normal per-package coverage accounting attributes the exercised Schema
// assembly code to internal/cli.
func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild() error = %v", err)
@@ -33,17 +33,17 @@ func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
if len(snapshot.Tools) == 0 {
t.Fatal("production Schema snapshot contains no tools")
}
registry, err := cli.AssembleSchemaRegistry(app.NewRootCommand())
registry, err := cli.AssembleSchemaRegistry(app.NewSchemaSourceRootCommand())
if err != nil {
t.Fatalf("AssembleSchemaRegistry() error = %v", err)
}
if len(registry.Products) == 0 {
t.Fatal("assembled production Schema registry contains no products")
}
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewRootCommand()); err != nil {
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err)
}
root = app.NewRootCommand()
root = app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
+7 -4
View File
@@ -23,6 +23,7 @@
"event": "metadata/event.json",
"live": "metadata/live.json",
"mail": "metadata/mail.json",
"mcp": "metadata/mcp.json",
"minutes": "metadata/minutes.json",
"oa": "metadata/oa.json",
"pat": "metadata/pat.json",
@@ -47,6 +48,7 @@
"event": "selection/event.json",
"live": "selection/live.json",
"mail": "selection/mail.json",
"mcp": "selection/mcp.json",
"minutes": "selection/minutes.json",
"oa": "selection/oa.json",
"pat": "selection/pat.json",
@@ -434,10 +436,6 @@
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
},
"chat media upload": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
},
"chat message list-emotion-replies": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
@@ -634,6 +632,11 @@
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_at": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "alias",
"target": "event consume",
@@ -0,0 +1,24 @@
{
"version": 1,
"source": {
"kind": "explicit",
"name": "dws-tool-metadata/mcp",
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
"channel": "open-source",
"reviewed": true
},
"tools": {
"mcp.url_get": {
"effect": "read",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"runtime_gate": "none",
"reviewed": true,
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
}
}
}
@@ -385,10 +385,6 @@
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
},
"chat media upload": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
},
"chat message list-emotion-replies": {
"status": "stale",
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
@@ -585,6 +581,11 @@
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_at": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "alias",
"target": "event consume",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 572,
"source_tools": 573,
"matched_tools": 71
},
"tools": {
@@ -9,7 +9,7 @@
},
"tools": {
"event.consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"use_when": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
@@ -20,8 +20,8 @@
"只看事件目录/字段时用 event list / event schema"
],
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -57,7 +57,7 @@
]
},
"event.schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"use_when": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
@@ -66,7 +66,7 @@
"要实际收事件时用 event consume"
],
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -0,0 +1,51 @@
{
"version": 1,
"source": {
"kind": "explicit",
"name": "dws-agent-selection/mcp",
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
"channel": "open-source",
"reviewed": true
},
"tools": {
"mcp.url_get": {
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
],
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"examples": [
"dws mcp url get 10043 --format json"
],
"reviewed": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source_refs": [
"internal/cli/schema_command_registry.json#mcp.url_get",
"cobra-help:dws mcp url get --help",
"internal/app/mcp_url_command.go",
"pkg/edition/default.go#openSupplementServers"
]
}
},
"products": {
"mcp": {
"agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息",
"use_when": [
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
],
"avoid_when": [
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
],
"reviewed": true,
"review_reason": "为公开 mcp 命令提供产品级 Agent 路由,并强调连接信息属于敏感凭据。",
"source_refs": [
"internal/cli/schema_command_registry.json#mcp",
"cobra-help:dws mcp --help",
"CommandRegistry:product=mcp"
]
}
}
}
+2 -2
View File
@@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m
}
raw := strings.TrimSpace(args[0])
if tool, ok := index.Resolve(raw); ok {
if tool, ok := index.ResolveQuery(raw); ok {
tool = schemaToolForResolvedPath(tool, raw)
return renderRegistryToolPayload(tool)
}
@@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an
}
func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec {
normalized := normalizeSchemaCLIPath(raw)
normalized := normalizeSchemaQueryCLIPath(raw)
if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath {
return tool
}
+4
View File
@@ -90,6 +90,10 @@ type Config struct {
// NormalizeFormat; an empty Format here defaults to NDJSON inside
// BuildPipeline.
Format Format
// Flatten records whether the caller explicitly selected a structured
// business projection. Projector remains the executable behavior; this
// field is surfaced in dry-run output so users can verify the final mode.
Flatten bool
// OutputDir, if non-empty, switches the fallback sink from stdout to
// "file per event" under this directory.
OutputDir string
+1
View File
@@ -132,6 +132,7 @@ func PrintDryRun(w io.Writer, cfg Config) {
fmt.Fprintf(w, " filter : %s\n", cfg.Filter)
}
fmt.Fprintf(w, " format : %s\n", cfg.Format)
fmt.Fprintf(w, " flatten : %v\n", cfg.Flatten)
if cfg.OutputDir != "" {
fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir)
}
+2 -1
View File
@@ -137,6 +137,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
c.EventTypes = []string{"im.*", "approval.*"}
c.Filter = "^im\\."
c.Format = FormatCompact
c.Flatten = true
c.OutputDir = "/tmp/events"
c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`})
c.MaxEvents = 5
@@ -151,7 +152,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
wants := []string{
"client_id", "workdir", "ipc_endpoint", "im.*,approval.*",
"^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5",
"duration", "true",
"duration", "flatten : true", "true",
}
for _, w := range wants {
if !strings.Contains(out, w) {
+33
View File
@@ -394,6 +394,39 @@ func outputSchema(eventKey string) map[string]any {
}
}
func transportEnvelopeSchema(eventKey string) map[string]any {
eventType := reflect.TypeOf(transport.Event{})
properties := make(map[string]any, eventType.NumField())
for i := 0; i < eventType.NumField(); i++ {
field := eventType.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
property := map[string]any{"type": schemaType(field.Type)}
switch name {
case "type":
property["description"] = "transport frame 类型"
property["enum"] = []string{string(transport.FrameTypeEvent)}
case "event_type":
property["description"] = "事件类型"
property["enum"] = []string{eventKey}
case "data":
property["description"] = "服务端业务 payload JSON 字符串"
property["content_media_type"] = "application/json"
case "headers":
property["description"] = "Stream transport headers"
property["additionalProperties"] = map[string]any{"type": "string"}
case "event_id":
property["description"] = "transport 事件 ID"
case "subscribe_id":
property["description"] = "个人事件订阅 ID"
}
properties[name] = property
}
return map[string]any{
"type": "object",
"properties": properties,
}
}
func outputTypeForEvent(eventKey string) reflect.Type {
switch {
case isMessageReceiveEvent(eventKey):
+12 -2
View File
@@ -258,8 +258,18 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
}
func BuildSchemaDocument(def Definition) SchemaDocument {
return BuildSchemaDocumentForMode(def, false)
}
func BuildSchemaDocumentForMode(def Definition, flatten bool) SchemaDocument {
requiredParams := make([]string, 0, len(def.RequiredParams))
requiredParams = append(requiredParams, def.RequiredParams...)
jqRootPath := ".data | fromjson"
schema := transportEnvelopeSchema(def.EventKey)
if flatten {
jqRootPath = "."
schema = outputSchema(def.EventKey)
}
return SchemaDocument{
EventKey: def.EventKey,
DisplayName: def.DisplayName,
@@ -268,8 +278,8 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
RuleType: def.RuleType,
RequiredParams: requiredParams,
Constraints: cloneParameterConstraints(def.Constraints),
JQRootPath: ".",
Schema: outputSchema(def.EventKey),
JQRootPath: jqRootPath,
Schema: schema,
}
}
+54 -13
View File
@@ -95,7 +95,7 @@ func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) {
}
}
func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -117,16 +117,16 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"required_params",
"jq_root_path",
"schema",
"type",
"seq",
"event_id",
"timestamp",
"event_born_time",
"event_type",
"subscribe_id",
"content",
"sender",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
"source_id",
"data",
"headers",
"received_at_unix_ms",
} {
if !strings.Contains(out, want) {
t.Fatalf("schema for %s missing %q: %s", eventKey, want, out)
@@ -144,7 +144,6 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"payload_schema",
"output_schema",
"data_json_path",
"headers",
"audit",
"tenant",
"subject",
@@ -152,13 +151,18 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"msgIdMetaq",
"at_users",
"sender_user_id",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
} {
if strings.Contains(out, leaked) {
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
}
}
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
if doc.JQRootPath != ".data | fromjson" {
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
}
if doc.RequiredParams == nil {
t.Fatalf("required_params = nil, want empty slice")
@@ -167,6 +171,38 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
wantProperties := []string{
"type", "seq", "event_id", "event_born_time", "event_corp_id",
"event_type", "event_unified_app_id", "event_scope", "subscribe_id",
"source_id", "rule_type", "data", "headers", "received_at_unix_ms",
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d transport fields", props, len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
})
}
}
func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
@@ -180,6 +216,11 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
for _, transportField := range []string{"data", "headers", "seq", "event_type"} {
if _, ok := props[transportField]; ok {
t.Fatalf("flattened schema exposed transport field %q", transportField)
}
}
})
}
}
@@ -286,7 +327,7 @@ func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocument(def)
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
+7 -4
View File
@@ -116,11 +116,14 @@ func NewWorkflowInvocation(legacyPath, workflowName string, steps []Invocation)
func MergePayloads(jsonPayload, paramsPayload string, overrides map[string]any) (map[string]any, error) {
merged := make(map[string]any)
for label, payload := range map[string]string{
"--json": jsonPayload,
"--params": paramsPayload,
for _, payload := range []struct {
label string
raw string
}{
{label: "--json", raw: jsonPayload},
{label: "--params", raw: paramsPayload},
} {
value, err := parseJSONObject(label, payload)
value, err := parseJSONObject(payload.label, payload.raw)
if err != nil {
return nil, err
}
+4
View File
@@ -52,6 +52,10 @@ func TestCrossPlatformCoverageMergePayloadsAndToolCallRequest(t *testing.T) {
if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 {
t.Fatalf("empty MergePayloads() = %#v, %v", empty, err)
}
merged, err = MergePayloads(`{"same":"json"}`, `{"same":"params"}`, nil)
if err != nil || merged["same"] != "params" {
t.Fatalf("MergePayloads() precedence = %#v, %v; want --params to win", merged, err)
}
for _, input := range []string{`{`, `[]`, `null`} {
if _, err := MergePayloads(input, "", nil); err == nil {
t.Errorf("MergePayloads(%q) error = nil", input)
@@ -18,7 +18,7 @@ func TestCrossPlatformCoverageGenerateProductionAgentMetadataPipeline(t *testing
if err != nil {
t.Fatal(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
@@ -46,7 +46,7 @@ var (
writeMetadataFileBytes = os.WriteFile
writeMetadataJSON = writeJSON
newMetadataRoot = app.NewRootCommand
newMetadataRoot = app.NewSchemaSourceRootCommand
buildEffectiveMetadata = cli.BuildEffectiveCommandRegistry
bindEffectiveMetadata = cli.BindEffectiveCommandRegistry
loadSelectionMetadataHints = cli.LoadAgentHintsFromSelectionForValidation
@@ -48,7 +48,7 @@ func main() {
fail(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if err := generateSchemaCatalog(root, resolvedSurfacePath, outputPath); err != nil {
fail(err)
}
@@ -131,7 +131,7 @@ func TestCrossPlatformCoverageGenerateSchemaCatalogFailureEdges(t *testing.T) {
}
func TestCrossPlatformCoverageGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolveCalls := 0
resolvedRegistryHash := ""
resolver := func(candidate *cobra.Command) (cli.ResolvedSchemaBuild, error) {
@@ -17,7 +17,7 @@ import (
)
var (
newSmokeRoot = app.NewRootCommand
newSmokeRoot = app.NewSchemaSourceRootCommand
buildEffectiveSmokeRegistry = cli.BuildEffectiveCommandRegistry
bindEffectiveSmokeRegistry = cli.BindEffectiveCommandRegistry
buildSmokeRegistryData = buildSmokeRegistry
+42 -10
View File
@@ -1433,19 +1433,25 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
富媒体消息(通过 --msg-type 指定类型):
image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)
file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送`,
本地图片 / 文件消息:
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
旧版内联图片消息:
仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。
当前 CLI 不提供本地文件到 mediaId 的上传能力。`,
Example: ` dws chat message send --group <openconversation_id> "hello"
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
# 发送图片
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>
# 发送本地文件/音频/视频(audio/video 是 file 的语义别名)
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --group <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --group <openconversation_id> --msg-type file --file-path ./report.pdf
# 发送本地音频/视频(audio/video 是 file 的语义别名)
dws chat message send --group <openconversation_id> --msg-type audio --file-path ./recording.mp3
dws chat message send --group <openconversation_id> --msg-type video --file-path ./demo.mp4
# 旧版内联图片:仅当上游已经持有有效 mediaId 时使用
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>
# 查询群 ID: dws chat search --query "群名"
# 查询用户 ID: dws contact user search --query "姓名"`,
Args: cobra.MaximumNArgs(1),
@@ -2400,13 +2406,13 @@ func newChatCommand() *cobra.Command {
_ = chatMessageSendCmd.Flags().MarkHidden("markdown")
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
chatMessageSendCmd.Flags().String("media-id", "", "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)")
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名")
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时可直接上传发送)")
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)")
chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节")
_ = chatMessageSendCmd.Flags().MarkHidden("dentry-id")
_ = chatMessageSendCmd.Flags().MarkHidden("space-id")
@@ -3143,6 +3149,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
# message-id: 从 dws chat message list 返回的 openMessageId
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Cobra validates required flags after PreRunE. Copy a supplied alias
// into the canonical flag first so --message-id can remain a hard
// required fact in both the executable and Agent Schema contracts.
if cmd.Flags().Changed("message-id") {
return nil
}
alias := ""
switch {
case cmd.Flags().Changed("msg-id"):
alias = "msg-id"
case cmd.Flags().Changed("open-message-id"):
alias = "open-message-id"
default:
return nil
}
value, _ := cmd.Flags().GetString(alias) // registered string flags above
return cmd.Flags().Set("message-id", value)
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil {
return err
@@ -3226,6 +3251,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id")
// Hidden aliases: agents routinely pass --msg-id / --open-message-id since
// the message-list output exposes the field as openMessageId/msgId. Accept
// them transparently instead of failing with "unknown flag".
chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名")
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id")
chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名")
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id")
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
@@ -3361,7 +3393,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id"))
if iconMediaID == "" {
return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId")
return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 请使用上游媒体上传能力返回的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传命令")
}
return callMCPToolOnServer("im", "update_group_icon", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
@@ -222,6 +222,12 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
tmp := t.TempDir()
base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...)
for _, alias := range []string{"--msg-id=mid", "--open-message-id=mid"} {
aliasArgs := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", alias, "--output=" + filepath.Join(tmp, "alias-dry")}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, aliasArgs...)
}
missingMessageID := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--output=" + filepath.Join(tmp, "missing-id")}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, missingMessageID...)
for _, tc := range []struct {
step scriptedToolStep
out string
@@ -0,0 +1,262 @@
package helpers
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type chatFilePathCall struct {
server string
tool string
args map[string]any
}
type chatFilePathCaller struct {
sequence []string
calls []chatFilePathCall
}
func (c *chatFilePathCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.sequence = append(c.sequence, tool)
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, chatFilePathCall{server: server, tool: tool, args: copied})
switch tool {
case "init_conversation_file_upload":
return textToolResult(`{"resourceUrl":"https://upload.example/file","uploadKey":"upload-key","headers":{"x-upload":"yes"}}`), nil
case "commit_conversation_file_upload":
return textToolResult(`{"result":{"dentryId":123,"spaceId":456}}`), nil
case "send_personal_message":
return textToolResult(`{"success":true}`), nil
default:
return nil, fmt.Errorf("unexpected tool call %s/%s", server, tool)
}
}
func (*chatFilePathCaller) Format() string { return "json" }
func (*chatFilePathCaller) DryRun() bool { return false }
func (*chatFilePathCaller) Fields() string { return "" }
func (*chatFilePathCaller) JQ() string { return "" }
func TestChatMessageSendFilePathUsesWukongUploadSequence(t *testing.T) {
previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args
t.Cleanup(func() {
deps = previousDeps
httpPutFile = previousPut
os.Args = previousArgs
})
filePath := filepath.Join(t.TempDir(), "image.png")
payload := []byte("png payload")
if err := os.WriteFile(filePath, payload, 0o600); err != nil {
t.Fatal(err)
}
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--group=cid",
"--msg-type=file",
"--file-path=" + filePath,
}
os.Args = append([]string{"dws", "chat"}, commandArgs...)
httpPutFile = func(_ context.Context, resourceURL string, headers map[string]string, localPath string, fileSize int64) error {
caller.sequence = append(caller.sequence, "HTTP PUT")
if resourceURL != "https://upload.example/file" {
t.Fatalf("resourceURL = %q", resourceURL)
}
if headers["x-upload"] != "yes" {
t.Fatalf("headers = %#v", headers)
}
if localPath != filePath || fileSize != int64(len(payload)) {
t.Fatalf("upload file = %q (%d), want %q (%d)", localPath, fileSize, filePath, len(payload))
}
return nil
}
err := runChatCoverageCommand(t, caller, commandArgs...)
if err != nil {
t.Fatalf("chat message send --file-path: %v", err)
}
wantSequence := []string{
"init_conversation_file_upload",
"HTTP PUT",
"commit_conversation_file_upload",
"send_personal_message",
}
if !reflect.DeepEqual(caller.sequence, wantSequence) {
t.Fatalf("call sequence = %#v, want %#v", caller.sequence, wantSequence)
}
if len(caller.calls) != 3 {
t.Fatalf("tool calls = %#v, want init, commit, send", caller.calls)
}
fileMD5 := fmt.Sprintf("%x", md5.Sum(payload))
wantInit := chatFilePathCall{
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openConversationId": "cid",
"fileName": "image.png",
"fileSize": int64(len(payload)),
"md5": fileMD5,
},
}
if !reflect.DeepEqual(caller.calls[0], wantInit) {
t.Fatalf("init call = %#v, want %#v", caller.calls[0], wantInit)
}
wantCommit := chatFilePathCall{
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openConversationId": "cid",
"uploadKey": "upload-key",
"fileName": "image.png",
"fileSize": int64(len(payload)),
"md5": fileMD5,
},
}
if !reflect.DeepEqual(caller.calls[1], wantCommit) {
t.Fatalf("commit call = %#v, want %#v", caller.calls[1], wantCommit)
}
send := caller.calls[len(caller.calls)-1]
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send call = %#v", send)
}
if send.args["openConversationId"] != "cid" {
t.Fatalf("send target = %#v", send.args["openConversationId"])
}
content, ok := send.args["content"].(string)
if !ok {
t.Fatalf("send content = %#v", send.args["content"])
}
var parsed struct {
DentryID int64 `json:"dentryId"`
SpaceID int64 `json:"spaceId"`
FileName string `json:"fileName"`
FileType string `json:"fileType"`
FilePath string `json:"filePath"`
FileSize int64 `json:"fileSize"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
t.Fatalf("decode send content %q: %v", content, err)
}
if parsed.DentryID != 123 || parsed.SpaceID != 456 ||
parsed.FileName != "image.png" || parsed.FileType != "png" ||
parsed.FilePath != "/image.png" || parsed.FileSize != int64(len(payload)) {
t.Fatalf("send content = %#v", parsed)
}
}
func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args
t.Cleanup(func() {
deps = previousDeps
httpPutFile = previousPut
os.Args = previousArgs
})
filePath := filepath.Join(t.TempDir(), "report.pdf")
payload := []byte("pdf payload")
if err := os.WriteFile(filePath, payload, 0o600); err != nil {
t.Fatal(err)
}
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--open-dingtalk-id=D-target",
"--msg-type=file",
"--file-path=" + filePath,
}
os.Args = append([]string{"dws", "chat"}, commandArgs...)
httpPutFile = func(_ context.Context, resourceURL string, _ map[string]string, localPath string, fileSize int64) error {
caller.sequence = append(caller.sequence, "HTTP PUT")
if resourceURL != "https://upload.example/file" || localPath != filePath || fileSize != int64(len(payload)) {
t.Fatalf("upload = %q, %q (%d)", resourceURL, localPath, fileSize)
}
return nil
}
if err := runChatCoverageCommand(t, caller, commandArgs...); err != nil {
t.Fatalf("chat message send --open-dingtalk-id --file-path: %v", err)
}
if len(caller.calls) != 3 {
t.Fatalf("tool calls = %#v, want init, commit, send", caller.calls)
}
fileMD5 := fmt.Sprintf("%x", md5.Sum(payload))
wantInit := chatFilePathCall{
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
},
}
if !reflect.DeepEqual(caller.calls[0], wantInit) {
t.Fatalf("init direct target call = %#v, want %#v", caller.calls[0], wantInit)
}
wantCommit := chatFilePathCall{
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"uploadKey": "upload-key",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
},
}
if !reflect.DeepEqual(caller.calls[1], wantCommit) {
t.Fatalf("commit direct target call = %#v, want %#v", caller.calls[1], wantCommit)
}
send := caller.calls[2]
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send direct target call = %#v", send)
}
if send.args["receiverOpenDingTalkId"] != "D-target" {
t.Fatalf("send direct target = %#v", send.args)
}
if _, ok := send.args["openDingTalkId"]; ok {
t.Fatalf("send direct target leaked upload target key: %#v", send.args)
}
}
func TestChatMessageSendFilePathRequiresFileMessageType(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
filePath := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(filePath, []byte("png"), 0o600); err != nil {
t.Fatal(err)
}
caller := &chatFilePathCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send",
"--group=cid",
"--file-path="+filePath,
)
if err == nil {
t.Fatal("bare --file-path succeeded without --msg-type=file")
}
if len(caller.calls) != 0 {
t.Fatalf("bare --file-path made remote calls: %#v", caller.calls)
}
}
+28 -170
View File
@@ -14,38 +14,23 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
var (
chatMediaResolveAppToken = mediaResolveAppToken
chatMediaUploadFile = mediaUploadFile
mediaCreateFormFile = func(w *multipart.Writer, field, name string) (io.Writer, error) { return w.CreateFormFile(field, name) }
mediaOpenFile = os.Open
mediaCopyFile = io.Copy
)
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>"
func newChatMediaGroup() *cobra.Command {
media := &cobra.Command{
Use: "media",
Short: "媒体文件管理",
Short: "已下线:媒体文件上传兼容入口",
Deprecated: "本地图片和文件请改用 " + chatMediaUploadReplacement,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
RunE: func(*cobra.Command, []string) error {
return chatMediaUploadDownlineError()
},
}
media.AddCommand(newChatMediaUploadCommand())
return media
@@ -53,160 +38,33 @@ func newChatMediaGroup() *cobra.Command {
func newChatMediaUploadCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传图片获取 mediaId(用于 chat message send --msg-type image)",
Example: " dws chat media upload --file ./screenshot.png\n" +
" dws chat media upload --file ./photo.jpg --type image",
Use: "upload",
Short: "已下线:请通过 chat message send 直接发送本地文件",
Deprecated: "请改用 " + chatMediaUploadReplacement,
Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。
发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。
该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。
如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`,
Example: " dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png\n" +
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath, _ := cmd.Flags().GetString("file")
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return apperrors.NewValidation("--file is required")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file: " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory")
}
mediaType, _ := cmd.Flags().GetString("type")
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
if mediaType == "" {
mediaType = "image"
}
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
defer cancel()
token, err := chatMediaResolveAppToken(ctx)
if err != nil {
return err
}
mediaID, err := chatMediaUploadFile(ctx, token, filePath, mediaType)
if err != nil {
return err
}
return writeCommandPayload(cmd, map[string]any{
"success": true,
"mediaId": mediaID,
})
RunE: func(*cobra.Command, []string) error {
return chatMediaUploadDownlineError()
},
}
cmd.Flags().String("file", "", "本地文件路径 (必填)")
cmd.Flags().String("type", "image", "媒体类型: image/voice/video/file")
// Keep the historical flags so existing argv remains parseable while the
// 1.x compatibility command returns an actionable migration error.
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path")
cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传")
return cmd
}
func mediaResolveAppToken(ctx context.Context) (string, error) {
return mediaResolveAppTokenWithRequest(ctx, http.NewRequestWithContext)
}
type mediaRequestFactory func(context.Context, string, string, io.Reader) (*http.Request, error)
func mediaResolveAppTokenWithRequest(ctx context.Context, newRequest mediaRequestFactory) (string, error) {
appKey := os.Getenv("DWS_CLIENT_ID")
appSecret := os.Getenv("DWS_CLIENT_SECRET")
if appKey == "" || appSecret == "" {
return "", apperrors.NewAuth(
"缺少应用凭证。chat media upload 需要 DWS_CLIENT_ID / DWS_CLIENT_SECRET 环境变量。\n" +
"请使用 dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET> 登录。")
}
endpoint := url.URL{Scheme: "https", Host: "oapi.dingtalk.com", Path: "/gettoken"}
endpoint.RawQuery = url.Values{
"appkey": []string{appKey},
"appsecret": []string{appSecret},
}.Encode()
req, err := newRequest(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return "", apperrors.NewAuth("构造访问令牌请求失败: " + err.Error())
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error())
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAuth(fmt.Sprintf("获取访问令牌 HTTP %d: %s", resp.StatusCode, string(raw)))
}
var parsed struct {
AccessToken string `json:"access_token"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", apperrors.NewAuth("gettoken 响应解析失败: " + string(raw))
}
if parsed.ErrCode != 0 || parsed.AccessToken == "" {
return "", apperrors.NewAuth(fmt.Sprintf("gettoken errcode=%d errmsg=%s", parsed.ErrCode, parsed.ErrMsg))
}
return parsed.AccessToken, nil
}
func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (string, error) {
return mediaUploadFileWithRequest(ctx, token, filePath, mediaType, http.NewRequestWithContext)
}
func mediaUploadFileWithRequest(ctx context.Context, token, filePath, mediaType string, newRequest mediaRequestFactory) (string, error) {
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
endpoint := url.URL{Scheme: "https", Host: "oapi.dingtalk.com", Path: "/media/upload"}
endpoint.RawQuery = url.Values{
"access_token": []string{token},
"type": []string{mediaType},
}.Encode()
req, err := newRequest(ctx, http.MethodPost, endpoint.String(), pr)
if err != nil {
_ = pr.CloseWithError(err)
_ = pw.CloseWithError(err)
return "", apperrors.NewAPI("construct media upload request: " + err.Error())
}
req.Header.Set("Content-Type", writer.FormDataContentType())
go func() {
defer pw.Close()
defer writer.Close()
part, err := mediaCreateFormFile(writer, "media", filepath.Base(filePath))
if err != nil {
pw.CloseWithError(err)
return
}
f, err := mediaOpenFile(filePath)
if err != nil {
pw.CloseWithError(err)
return
}
defer f.Close()
if _, err := mediaCopyFile(part, f); err != nil {
pw.CloseWithError(err)
}
}()
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
if err != nil {
return "", apperrors.NewAPI("media upload failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAPI(fmt.Sprintf("media upload HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
var parsed struct {
MediaID string `json:"media_id"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", apperrors.NewAPI("media upload 响应解析失败: " + string(body))
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", apperrors.NewAPI(fmt.Sprintf("media upload errcode=%d errmsg=%s body=%s", parsed.ErrCode, parsed.ErrMsg, string(body)))
}
return strings.TrimSpace(parsed.MediaID), nil
func chatMediaUploadDownlineError() error {
return apperrors.NewValidation(
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" +
" 本地图片或文件请改用: " + chatMediaUploadReplacement +
";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id <mediaId>。",
)
}
@@ -1,87 +1,36 @@
package helpers
import (
"bytes"
"context"
"errors"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageChatMediaUploadCommandRemainingCoverage(t *testing.T) {
file := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
t.Fatal(err)
}
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", t.TempDir()); err == nil {
t.Fatal("directory upload returned nil")
func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *testing.T) {
group := newChatMediaGroup()
if group.Deprecated == "" || group.Hidden || !group.Runnable() {
t.Fatalf("media group compatibility contract: deprecated=%q hidden=%v runnable=%v", group.Deprecated, group.Hidden, group.Runnable())
}
origResolve, origUpload := chatMediaResolveAppToken, chatMediaUploadFile
t.Cleanup(func() {
chatMediaResolveAppToken = origResolve
chatMediaUploadFile = origUpload
})
chatMediaResolveAppToken = func(context.Context) (string, error) { return "", errors.New("token") }
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil {
t.Fatal("token failure returned nil")
}
chatMediaResolveAppToken = func(context.Context) (string, error) { return "token", nil }
chatMediaUploadFile = func(context.Context, string, string, string) (string, error) { return "", errors.New("upload") }
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil {
t.Fatal("upload failure returned nil")
}
var mediaType string
chatMediaUploadFile = func(_ context.Context, _, _, typ string) (string, error) {
mediaType = typ
return "media-id", nil
}
cmd := newChatMediaUploadCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--file", file, "--type", " "})
if err := cmd.Execute(); err != nil || mediaType != "image" || !strings.Contains(out.String(), "media-id") {
t.Fatalf("success type=%q output=%q err=%v", mediaType, out.String(), err)
if cmd.Deprecated == "" || cmd.Hidden || !cmd.Runnable() {
t.Fatalf("media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", cmd.Deprecated, cmd.Hidden, cmd.Runnable())
}
}
func TestCrossPlatformCoverageMediaUploadMultipartWriterRemainingFailures(t *testing.T) {
file := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
t.Fatal(err)
}
origTransport := http.DefaultTransport
origCreate, origOpen, origCopy := mediaCreateFormFile, mediaOpenFile, mediaCopyFile
t.Cleanup(func() {
http.DefaultTransport = origTransport
mediaCreateFormFile, mediaOpenFile, mediaCopyFile = origCreate, origOpen, origCopy
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
_, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
for _, flag := range []string{"file", "type"} {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("media upload lost historical --%s flag", flag)
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"media_id":"id"}`)), Request: req}, nil
})
}
mediaCreateFormFile = func(*multipart.Writer, string, string) (io.Writer, error) { return nil, errors.New("part") }
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
t.Fatal("form part failure returned nil")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--file", "/path/that/does/not/exist.png", "--type", "image"})
err := cmd.Execute()
if err == nil {
t.Fatal("deprecated media upload returned nil error")
}
mediaCreateFormFile = origCreate
mediaOpenFile = func(string) (*os.File, error) { return nil, errors.New("open") }
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
t.Fatal("open failure returned nil")
}
mediaOpenFile = origOpen
mediaCopyFile = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
t.Fatal("copy failure returned nil")
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("media upload migration error missing %q: %v", want, err)
}
}
}
+1 -83
View File
@@ -3,14 +3,11 @@ package helpers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -117,86 +114,7 @@ func TestCrossPlatformCoverageMailHTTPTransfersCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatMediaHTTPAndDocVersionsCoverage(t *testing.T) {
previousTransport := http.DefaultTransport
mode := "success"
var requestQuery url.Values
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if mode == "transport-error" {
return nil, errors.New("transport")
}
if req.Body != nil {
if _, err := io.ReadAll(req.Body); err != nil {
return nil, err
}
}
requestQuery = req.URL.Query()
status := http.StatusOK
body := `{"access_token":"token","errcode":0}`
if strings.Contains(req.URL.Path, "media/upload") {
body = `{"media_id":"media-id","errcode":0}`
}
switch mode {
case "http-error":
status = http.StatusInternalServerError
body = `failure`
case "invalid-json":
body = `{`
case "api-error":
body = `{"errcode":1,"errmsg":"failed"}`
}
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
if _, err := mediaResolveAppToken(context.Background()); err == nil {
t.Fatal("missing media credentials succeeded")
}
t.Setenv("DWS_CLIENT_ID", "client")
t.Setenv("DWS_CLIENT_SECRET", "secret")
for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} {
mode = current
_, _ = mediaResolveAppToken(context.Background())
}
t.Setenv("DWS_CLIENT_ID", "client&scope=chat")
t.Setenv("DWS_CLIENT_SECRET", "secret=with?reserved")
mode = "success"
if _, err := mediaResolveAppToken(context.Background()); err != nil {
t.Fatal(err)
}
if requestQuery.Get("appkey") != "client&scope=chat" || requestQuery.Get("appsecret") != "secret=with?reserved" {
t.Fatalf("token query was not encoded: %v", requestQuery)
}
file := filepath.Join(t.TempDir(), "image.png")
if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
t.Fatal(err)
}
for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} {
mode = current
_, _ = mediaUploadFile(context.Background(), "token", file, "image")
}
mode = "success"
if _, err := mediaUploadFile(context.Background(), "token&scope=chat", file, "image&type=file"); err != nil {
t.Fatal(err)
}
if requestQuery.Get("access_token") != "token&scope=chat" || requestQuery.Get("type") != "image&type=file" {
t.Fatalf("upload query was not encoded: %v", requestQuery)
}
mode = "success"
_, _ = mediaUploadFile(context.Background(), "token", filepath.Join(t.TempDir(), "missing"), "image")
requestFailure := func(context.Context, string, string, io.Reader) (*http.Request, error) {
return nil, errors.New("request")
}
if _, err := mediaResolveAppTokenWithRequest(context.Background(), requestFailure); err == nil {
t.Fatal("token request construction failure returned nil")
}
if _, err := mediaUploadFileWithRequest(context.Background(), "token", file, "image", requestFailure); err == nil {
t.Fatal("upload request construction failure returned nil")
}
func TestCrossPlatformCoverageDocVersionsCoverage(t *testing.T) {
for _, value := range []any{float64(3), float64(3.5), "3", "bad", jsonNumber("3"), jsonNumber("bad"), true} {
_ = docVersionNumberMatches(value, 3)
}
+77 -17
View File
@@ -14,7 +14,9 @@
package plugin
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
@@ -24,6 +26,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
const maxPluginCLIOverlayBytes = 4 << 20
// UserContext holds the minimal user identity fields injected into
// stdio plugin subprocesses via environment variables.
type UserContext struct {
@@ -111,23 +115,9 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
continue
}
overlay := mcptypes.CLIOverlay{}
if len(srv.CLI) > 0 {
if err := json.Unmarshal(srv.CLI, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLIOverlay",
"plugin", p.Manifest.Name,
"server", key,
"error", err,
)
}
}
// Ensure the overlay has an ID — fall back to server key.
if overlay.ID == "" {
overlay.ID = key
}
if overlay.Command == "" {
overlay.Command = key
overlay, ok := p.ResolveCLIOverlay(key)
if !ok {
continue
}
source := "plugin"
@@ -154,3 +144,73 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
}
return descriptors
}
// ResolveCLIOverlay resolves inline or external manifest CLI metadata exactly
// once. External files are opened relative to the plugin root with os.Root so
// absolute paths, parent traversal, and escaping symlinks fail closed.
func (p *Plugin) ResolveCLIOverlay(serverKey string) (mcptypes.CLIOverlay, bool) {
overlay := mcptypes.CLIOverlay{
ID: serverKey,
Command: serverKey,
}
server, ok := p.Manifest.MCPServers[serverKey]
if !ok || len(server.CLI) == 0 {
return overlay, true
}
data := []byte(strings.TrimSpace(string(server.CLI)))
if len(data) == 0 {
return overlay, true
}
if data[0] == '"' {
var relativePath string
if err := json.Unmarshal(data, &relativePath); err != nil ||
strings.TrimSpace(relativePath) == "" {
slog.Warn("plugin: invalid external CLI overlay path",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
root, err := os.OpenRoot(p.Root)
if err != nil {
slog.Warn("plugin: failed to open plugin root",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer root.Close()
file, err := root.Open(relativePath)
if err != nil {
slog.Warn("plugin: failed to open CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer file.Close()
data, err = io.ReadAll(io.LimitReader(file, maxPluginCLIOverlayBytes+1))
if err != nil || len(data) > maxPluginCLIOverlayBytes {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
slog.Warn("plugin: CLI overlay contains trailing JSON",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if strings.TrimSpace(overlay.ID) == "" {
overlay.ID = serverKey
}
if strings.TrimSpace(overlay.Command) == "" {
overlay.Command = serverKey
}
return overlay, true
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package plugin
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestResolveCLIOverlayDefaultsAndInlineErrors(t *testing.T) {
plugin := &Plugin{
Root: t.TempDir(),
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"empty": {CLI: nil},
"whitespace": {CLI: json.RawMessage(" \n\t")},
"fallback": {CLI: json.RawMessage(`{"id":" ","command":""}`)},
"malformed": {CLI: json.RawMessage(`{`)},
"unknown": {CLI: json.RawMessage(`{"id":"plugin","unknownField":true}`)},
"unknownFlag": {CLI: json.RawMessage(`{
"toolOverrides":{"tool":{"flags":{"value":{"unknownFlagField":true}}}}
}`)},
"trailing": {CLI: json.RawMessage(`{} {}`)},
"legacyTools": {CLI: json.RawMessage(`{
"tools":[{
"name":"tool","cliName":"leaf","title":"Title","description":"Description",
"isSensitive":true,"category":"read","hidden":true,
"flags":{"value":{"alias":"value-alias","shorthand":"v"}}
}]
}`)},
}},
}
for _, serverKey := range []string{"missing", "empty", "whitespace", "fallback"} {
t.Run(serverKey, func(t *testing.T) {
overlay, ok := plugin.ResolveCLIOverlay(serverKey)
if !ok || overlay.ID != serverKey || overlay.Command != serverKey {
t.Fatalf("ResolveCLIOverlay(%q) = (%#v, %v), want default overlay", serverKey, overlay, ok)
}
})
}
legacy, ok := plugin.ResolveCLIOverlay("legacyTools")
if !ok || len(legacy.Tools) != 1 || legacy.Tools[0].CLIName != "leaf" ||
legacy.Tools[0].Flags["value"].Alias != "value-alias" {
t.Fatalf("ResolveCLIOverlay(legacyTools) = (%#v, %v), want historical tool metadata", legacy, ok)
}
if overlay, ok := plugin.ResolveCLIOverlay("malformed"); ok {
t.Fatalf("ResolveCLIOverlay(malformed) = (%#v, true), want failure", overlay)
}
for _, serverKey := range []string{"unknown", "unknownFlag", "trailing"} {
if overlay, ok := plugin.ResolveCLIOverlay(serverKey); ok {
t.Fatalf("ResolveCLIOverlay(%s) = (%#v, true), want strict failure", serverKey, overlay)
}
}
}
func TestResolveCLIOverlayRejectsInvalidExternalFiles(t *testing.T) {
root := t.TempDir()
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"external": {},
}},
}
server := plugin.Manifest.MCPServers["external"]
t.Run("malformed path JSON", func(t *testing.T) {
server.CLI = json.RawMessage(`"unterminated`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want malformed path failure", overlay)
}
})
t.Run("blank path", func(t *testing.T) {
server.CLI = json.RawMessage(`" "`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want blank path failure", overlay)
}
})
t.Run("missing root", func(t *testing.T) {
plugin.Root = filepath.Join(root, "does-not-exist")
server.CLI = json.RawMessage(`"overlay.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing root failure", overlay)
}
plugin.Root = root
})
t.Run("missing file", func(t *testing.T) {
server.CLI = json.RawMessage(`"missing.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing file failure", overlay)
}
})
t.Run("read error", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(root, "overlay-dir"), 0o700); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"overlay-dir"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want directory read failure", overlay)
}
})
t.Run("oversized file", func(t *testing.T) {
path := filepath.Join(root, "oversized.json")
if err := os.WriteFile(path, bytes.Repeat([]byte(" "), maxPluginCLIOverlayBytes+1), 0o600); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"oversized.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want oversized file failure", overlay)
}
})
}
func TestResolveCLIOverlayExternalFileAppliesFallbackIdentity(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "overlay.json"), []byte(`{
"id":"",
"command":" ",
"aliases":["conference"]
}`), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"server-key": {CLI: json.RawMessage(`"overlay.json"`)},
}},
}
overlay, ok := plugin.ResolveCLIOverlay("server-key")
if !ok || overlay.ID != "server-key" || overlay.Command != "server-key" ||
len(overlay.Aliases) != 1 || overlay.Aliases[0] != "conference" {
t.Fatalf("ResolveCLIOverlay() = (%#v, %v), want fallback identity with file metadata", overlay, ok)
}
}
+10 -6
View File
@@ -131,22 +131,19 @@ func TestCrossPlatformCoveragePluginConverterEdges(t *testing.T) {
t.Fatalf("StdioClients(user) length = %d", len(got))
}
descriptors := p.ToServerDescriptors()
if len(descriptors) != 2 {
if len(descriptors) != 1 {
t.Fatalf("descriptors length = %d", len(descriptors))
}
seenDefault := false
seenOverlay := false
for _, d := range descriptors {
switch d.Key {
case "http-default":
seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta
case "http-overlay":
seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" &&
d.AuthHeaders["Authorization"] == "Bearer secret"
}
}
if !seenDefault || !seenOverlay {
t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors)
if !seenOverlay {
t.Fatalf("valid descriptor overlay not covered: %#v", descriptors)
}
dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" {
@@ -300,6 +297,7 @@ func TestCrossPlatformCoverageManifestEdges(t *testing.T) {
func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
oldHome := pluginUserHomeDir
t.Cleanup(func() { pluginUserHomeDir = oldHome })
t.Setenv("DWS_CONFIG_DIR", "")
home := t.TempDir()
pluginUserHomeDir = func() (string, error) { return home, nil }
defaultLoader := NewLoader("1.2.3")
@@ -310,6 +308,12 @@ func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") {
t.Fatalf("NewLoader error path = %q", got)
}
customConfig := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", customConfig)
if got := NewLoader("dev").PluginsDir; got != filepath.Join(customConfig, "plugins") {
t.Fatalf("NewLoader custom config path = %q", got)
}
t.Setenv("DWS_CONFIG_DIR", "")
root := t.TempDir()
l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+6 -2
View File
@@ -56,9 +56,13 @@ var (
// NewLoader creates a Loader with default paths.
func NewLoader(cliVersion string) *Loader {
home, _ := pluginUserHomeDir()
configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR"))
if configDir == "" {
home, _ := pluginUserHomeDir()
configDir = filepath.Join(home, ".dws")
}
return &Loader{
PluginsDir: filepath.Join(home, ".dws", "plugins"),
PluginsDir: filepath.Join(configDir, "plugins"),
CLIVersion: cliVersion,
}
}
+77 -2
View File
@@ -74,6 +74,56 @@ func TestParseManifest(t *testing.T) {
}
}
func TestResolveCLIOverlayExternalFileIsRootContained(t *testing.T) {
root := t.TempDir()
overlayPath := filepath.Join(root, "overlay.json")
if err := os.WriteFile(overlayPath, []byte(`{
"id":"external-id",
"command":"external-command",
"toolOverrides":{"ping":{"cliName":"ping"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
loaded := &Plugin{
Root: root,
Manifest: Manifest{
Name: "external-plugin",
MCPServers: map[string]*MCPServer{
"external": {
Type: "streamable-http",
Endpoint: "https://example.invalid/mcp",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
}
overlay, ok := loaded.ResolveCLIOverlay("external")
if !ok || overlay.ID != "external-id" || overlay.Command != "external-command" {
t.Fatalf("external overlay = (%#v, %v)", overlay, ok)
}
descriptors := loaded.ToServerDescriptors()
if len(descriptors) != 1 || descriptors[0].CLI.ID != "external-id" {
t.Fatalf("external HTTP descriptors = %#v", descriptors)
}
outside := filepath.Join(t.TempDir(), "outside-overlay.json")
if err := os.WriteFile(outside, []byte(`{"id":"escaped"}`), 0o600); err != nil {
t.Fatal(err)
}
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"../outside-overlay.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("parent traversal resolved overlay %#v", overlay)
}
link := filepath.Join(root, "escaped-link.json")
if err := os.Symlink(outside, link); err == nil {
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"escaped-link.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("escaping symlink resolved overlay %#v", overlay)
}
}
}
func TestManifestValidate(t *testing.T) {
tests := []struct {
name string
@@ -219,8 +269,23 @@ func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) {
func TestPluginToServerDescriptors(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
"command": "conference",
"id": "conference",
"command": "conference",
"description": "video conference",
"groups": map[string]any{
"camera": map[string]any{"description": "camera control"},
},
"toolOverrides": map[string]any{
"open_camera": map[string]any{
"cliName": "open",
"group": "camera",
"description": "open camera",
"isSensitive": true,
"flags": map[string]any{
"device_id": map[string]any{"description": "camera device"},
},
},
},
})
p := &Plugin{
@@ -262,6 +327,16 @@ func TestPluginToServerDescriptors(t *testing.T) {
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
}
override := d.CLI.ToolOverrides["open_camera"]
if d.CLI.Description != "video conference" ||
d.CLI.Groups["camera"].Description != "camera control" ||
override.CLIName != "open" ||
override.Group != "camera" ||
override.Description != "open camera" ||
!override.IsSensitive ||
override.Flags["device_id"].Description != "camera device" {
t.Fatalf("CLI overlay command metadata was not preserved: %#v", d.CLI)
}
}
func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
@@ -0,0 +1,33 @@
// 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.
package builtin_test
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
)
func TestBaseCommandsExposeSortedDistributionShortcuts(t *testing.T) {
commands := builtin.BaseCommands()
if len(commands) == 0 {
t.Fatal("BaseCommands returned no distribution shortcuts")
}
for index, command := range commands {
if index > 0 && commands[index-1].Name() > command.Name() {
t.Fatalf("BaseCommands are not sorted: %q before %q", commands[index-1].Name(), command.Name())
}
children := command.Commands()
if len(children) == 0 {
t.Fatalf("base shortcut service %q has no commands", command.Name())
}
for _, child := range children {
if !strings.HasPrefix(child.Name(), "+") {
t.Fatalf("base shortcut %q under %q is not mounted as a +command", child.Name(), command.Name())
}
}
}
}
+6
View File
@@ -50,3 +50,9 @@ import (
func Commands() []*cobra.Command {
return shortcut.Commands()
}
// BaseCommands returns only distribution-owned shortcuts for Schema and
// interface generation.
func BaseCommands() []*cobra.Command {
return shortcut.BuiltInCommands()
}
+33 -17
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// MessagesSend sends a text/markdown message as the current user
@@ -242,6 +243,11 @@ var MessagesList = shortcut.Shortcut{
// clean output projection. Both the list container and the per-item field names are
// probed defensively across candidate keys, so an empty or unexpected shape
// yields an empty list rather than a crash or fabricated data.
//
// Text goes through the shared chatmsg projection so card/auto-reply JSON is
// rendered readable and encrypted ciphertext is marked, and forwarded chat
// records ("聊天记录") expand their nested messages under "forwarded" instead of
// collapsing to a "[卡片]" summary.
func listMessagesProject(data map[string]any) []map[string]any {
raw := listMessagesResolveList(data)
out := make([]map[string]any, 0, len(raw))
@@ -250,29 +256,39 @@ func listMessagesProject(data map[string]any) []map[string]any {
if !ok {
continue
}
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok {
row["text"] = v
}
if len(row) > 0 {
if row := listMessageProjectOne(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// listMessageProjectOne projects a single message into the native
// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused
// recursively for forwarded chat records.
func listMessageProjectOne(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if text := chatmsg.Text(m); text != nil {
row["text"] = text
}
if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// listMessagesResolveList locates the list payload, tolerating a bare top-level
// array container or nesting one level deeper under a common envelope key.
func listMessagesResolveList(data map[string]any) []any {
@@ -0,0 +1,56 @@
// 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 chat
import (
"strings"
"testing"
)
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestListMessageProjectOne(t *testing.T) {
// full field mapping + forwarded expansion; an encrypted body is marked (no
// cross-conversation recovery), not leaked as base64.
row := listMessageProjectOne(map[string]any{
"openMessageId": "mid",
"senderOpenDingTalkId": "DXYZ",
"msgType": "text",
"createTime": "2026-07-19 13:37:03",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"},
},
})
if row["messageId"] != "mid" || row["senderId"] != "DXYZ" || row["msgType"] != "text" {
t.Fatalf("field mapping = %#v", row)
}
if row["createTime"] != "2026-07-19 13:37:03" {
t.Errorf("createTime = %v", row["createTime"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") {
t.Errorf("encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 || fwd[0]["messageId"] != "c1" || fwd[0]["text"] != "子消息" {
t.Errorf("forwarded = %#v", row["forwarded"])
}
// a bare message with no recognizable fields → empty row (no keys)
row = listMessageProjectOne(map[string]any{"unrelated": 1})
if len(row) != 0 {
t.Errorf("empty message row = %#v, want no keys", row)
}
}
@@ -16,6 +16,7 @@ package chat
import (
"context"
"io"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -129,3 +130,48 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
t.Run("recognize open DingTalk IDs", func(t *testing.T) {
tests := []struct {
value string
want bool
}{
{value: "DingTalk-open-id", want: true},
{value: " dingtalk-open-id ", want: true},
{value: "user-id", want: false},
{value: " ", want: false},
}
for _, tc := range tests {
if got := isOpenID(tc.value); got != tc.want {
t.Errorf("isOpenID(%q) = %v, want %v", tc.value, got, tc.want)
}
}
})
t.Run("split mixed IDs", func(t *testing.T) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
}
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
}
})
t.Run("parse numeric IDs", func(t *testing.T) {
got, err := toInt64Slice([]string{" 1 ", "", "-2"})
if err != nil {
t.Fatal(err)
}
if want := []int64{1, -2}; !reflect.DeepEqual(got, want) {
t.Fatalf("numeric IDs = %#v, want %#v", got, want)
}
if _, err := toInt64Slice([]string{"not-a-number"}); err == nil {
t.Fatal("invalid integer unexpectedly succeeded")
}
if _, err := toInt64Slice([]string{"", " "}); err == nil {
t.Fatal("empty integer list unexpectedly succeeded")
}
})
}
+292
View File
@@ -0,0 +1,292 @@
// 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 chatmsg holds the shared, read-only projection helpers for DingTalk
// message-list responses (list_individual_chat_message,
// list_conversation_message_v2, search_at_me_message, search_messages_by_keyword,
// list_topic_replies, …). Several shortcuts reshape those raw responses into a
// clean speaker/text/time list; centralising the fiddly bits here keeps them
// consistent and fixed in one place:
//
// - Sender: the display name lives under the bare "sender" key, forwarded
// entries carry the literal string "null", and some responses nest the
// speaker in a {name:…} object — all handled here.
// - Text: out-of-office auto-replies / cards arrive as raw rich-content JSON,
// and card/robot messages arrive as undecryptable ciphertext; CleanText
// renders the former to readable text and marks the latter, WITHOUT ever
// rewriting ordinary text that merely contains a JSON fragment.
// - Forwarded: a forwarded chat record ("聊天记录") hides its real per-message
// bodies in forwardMessages while the top-level content is a lossy summary.
package chatmsg
import (
"encoding/json"
"regexp"
"strings"
)
// Sender reads a message's speaker display name, tolerating common sender-name
// keys. The message-list responses carry the display name under the bare
// "sender" key (verified live), so it is probed first; the remaining aliases and
// the *Id fallbacks keep the projection resilient to other shapes. The literal
// string "null" (forwarded entries) and the empty string are treated as absent,
// and a nested {name:…} sender object yields its display name rather than the
// raw object.
func Sender(m map[string]any) any {
for _, key := range []string{"sender", "senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId", "senderOpenDingTalkId"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t == "" || t == "null" {
continue
}
return t
case map[string]any:
// Nested sender object: extract a display-name field; never return
// the raw map (it would surface a JSON object and block fallbacks).
if name := senderDisplayName(t); name != "" {
return name
}
continue
default:
// Scalar id (e.g. numeric) — usable as-is.
return v
}
}
return nil
}
// senderDisplayName extracts a human name from a nested sender object.
func senderDisplayName(m map[string]any) string {
for _, k := range []string{"name", "nick", "userName", "staffName", "displayName", "senderName"} {
if s, ok := m[k].(string); ok {
if s = strings.TrimSpace(s); s != "" && s != "null" {
return s
}
}
}
return ""
}
// Text reads a message's textual content (tolerating common text keys and one
// level of nesting) and runs it through CleanText.
func Text(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return CleanText(t)
}
case map[string]any:
for _, inner := range []string{"text", "content", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return CleanText(s)
}
}
}
}
return nil
}
// CreateTime reads a message's create/send time under whichever candidate key is
// present, returning the raw value.
func CreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
// Forwarded projects the nested messages of a forwarded chat record. The caller
// supplies its own per-message projection so each command keeps its own row
// shape; project is applied recursively, so multi-level forwards expand too.
func Forwarded(m map[string]any, project func(map[string]any) map[string]any) []map[string]any {
raw, ok := m["forwardMessages"].([]any)
if !ok || len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if sub, ok := e.(map[string]any); ok {
out = append(out, project(sub))
}
}
return out
}
// CleanText makes a message body human-readable WITHOUT ever rewriting ordinary
// text. It only transforms a body that is a genuine DingTalk structured message:
//
// - Encrypted card/robot ciphertext (base64 + "||v||t||len" trailer) → a clear
// "[加密消息]" marker instead of the raw base64.
// - A rich-content card (out-of-office auto-reply, link/preview card, …) whose
// lines include at least one recognised rich-content block → the readable
// text extracted from those blocks, with the card's decorative JSON lines and
// "empty" placeholders dropped.
//
// Crucially, if NO line is a recognised rich-content block (e.g. ordinary text
// that merely embeds a `{"approved":false}` fragment), the original string is
// returned verbatim — a JSON line is never silently dropped.
func CleanText(s string) string {
if IsEncrypted(s) {
return "[加密消息,无法解码]"
}
// Fast path: no JSON delimiters at all — the overwhelming common case.
if !strings.ContainsAny(s, "{[") {
return s
}
lines := strings.Split(s, "\n")
isJSON := make([]bool, len(lines))
isDecoration := make([]bool, len(lines))
extracted := make([][]string, len(lines))
anyExtracted := false
for i, line := range lines {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") {
continue
}
var v any
if json.Unmarshal([]byte(t), &v) != nil {
continue
}
isJSON[i] = true
isDecoration[i] = isKnownRichDecoration(v)
if texts := richItemTexts(v); len(texts) > 0 {
extracted[i] = texts
anyExtracted = true
}
}
// No recognised rich-content block anywhere → treat the whole body as plain
// text (which may merely contain a JSON fragment) and return it untouched.
if !anyExtracted {
return s
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if len(extracted[i]) > 0 {
out = append(out, extracted[i]...)
continue
}
// In card mode, drop only JSON shapes known to be card decoration.
// Unrecognised JSON may be user-authored message content and must remain
// verbatim even when another line contains a rich-content block.
if isJSON[i] && isDecoration[i] {
continue
}
if t := strings.TrimSpace(line); t == "" || t == "empty" {
continue
}
out = append(out, line)
}
// anyExtracted is true here, so out always holds at least one non-empty
// extracted text — the joined result is never empty.
return strings.TrimSpace(strings.Join(out, "\n"))
}
// isKnownRichDecoration recognises the two decoration records emitted alongside
// DingTalk rich-content bodies. Keep this deliberately narrow: an arbitrary JSON
// object in the same message is user content unless its shape is known here.
func isKnownRichDecoration(node any) bool {
m, ok := node.(map[string]any)
if !ok {
return false
}
_, hasPreviewURL := m["previewUrl"]
_, hasTitle := m["title"]
_, hasAutoLayout := m["autoLayout"]
_, hasEnableForward := m["enableForward"]
return (hasPreviewURL && hasTitle) || (hasAutoLayout && hasEnableForward)
}
// richItemTexts walks a decoded DingTalk rich-content blob and returns the
// readable text carried by its rich-content items (items[].data.text). It only
// harvests item bodies, so decorative fields (card titles, preview URLs, layout
// config) contribute nothing and are dropped. An empty result means "not a
// recognised rich-content block".
func richItemTexts(node any) []string {
var texts []string
var walk func(n any)
walk = func(n any) {
switch t := n.(type) {
case []any:
for _, e := range t {
walk(e)
}
case map[string]any:
if items, ok := t["items"].([]any); ok {
for _, it := range items {
mm, ok := it.(map[string]any)
if !ok {
continue
}
data, ok := mm["data"].(map[string]any)
if !ok {
continue
}
if s, ok := data["text"].(string); ok {
if s = strings.TrimSpace(s); s != "" {
texts = append(texts, s)
}
}
}
}
for _, e := range t {
walk(e)
}
}
}
walk(node)
return texts
}
// encryptedTrailerRE matches DingTalk's encrypted-message trailer
// "||<version>||<type>||<len>" (e.g. "||2||1||196") anchored at the end.
var encryptedTrailerRE = regexp.MustCompile(`\|\|\d+\|\|\d+\|\|\d+\s*$`)
// IsEncrypted reports whether a message body is a raw DingTalk encrypted-message
// ciphertext: a base64 blob (DingTalk wraps it across several lines) followed by
// the "||v||t||len" trailer. It is intentionally strict — both the trailer and a
// pure-base64 body are required — so ordinary text (CJK, punctuation, …) never
// trips it.
func IsEncrypted(s string) bool {
s = strings.TrimSpace(s)
if !encryptedTrailerRE.MatchString(s) {
return false
}
body := strings.TrimSpace(encryptedTrailerRE.ReplaceAllString(s, ""))
if len(body) < 32 {
return false
}
for _, r := range body {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
r == '+', r == '/', r == '=', r == '\n', r == '\r', r == ' ', r == '\t':
default:
return false
}
}
return true
}
+176
View File
@@ -0,0 +1,176 @@
// 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 chatmsg
import (
"strings"
"testing"
)
func TestSender(t *testing.T) {
// The display name lives under the bare "sender" key.
if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" {
t.Fatalf("sender = %v, want 念晨", got)
}
// Falls back to the open id when no display name is present.
if got := Sender(map[string]any{"senderOpenDingTalkId": "DXYZ"}); got != "DXYZ" {
t.Fatalf("sender fallback = %v, want DXYZ", got)
}
// forwardMessages entries carry the literal string "null" — treat as absent.
if got := Sender(map[string]any{"sender": "null"}); got != nil {
t.Fatalf("sender \"null\" = %v, want nil", got)
}
if got := Sender(map[string]any{"sender": "null", "senderName": "念晨"}); got != "念晨" {
t.Fatalf("sender \"null\" fallthrough = %v, want 念晨", got)
}
// A nested {name:…} sender object yields its display name, not the raw map.
if got := Sender(map[string]any{"sender": map[string]any{"name": "Alice"}}); got != "Alice" {
t.Fatalf("nested sender = %v, want Alice", got)
}
// A nested sender object with no usable name must not block the fallback.
if got := Sender(map[string]any{"sender": map[string]any{"foo": "bar"}, "senderName": "Bob"}); got != "Bob" {
t.Fatalf("nested-no-name fallthrough = %v, want Bob", got)
}
// A scalar numeric id is returned as-is.
if got := Sender(map[string]any{"senderId": float64(42)}); got != float64(42) {
t.Fatalf("numeric sender id = %v", got)
}
}
func TestCleanText(t *testing.T) {
// Out-of-office auto-reply: readable body lives in items[].data.text; the
// decorative preview/config JSON lines and "empty" placeholder are dropped.
autoReply := "* 仅你和对方可见\n" +
`[{"text":{"minSupportVersion":"1.1","translateMap":{},"version":"1.2","items":[{"fallbackKey":"","data":{"text":"你好,我在出差中,消息回复可能不及时。"},"style":{"size":15,"bold":0},"type":"text"}]},"type":"markdown"}]` + "\n" +
`{"previewUrl":"dingtalk://x","title":{"text":"自动回复","type":"text"}}` + "\n" +
"empty\n" +
`{"autoLayout":false,"enableForward":false}`
if got, want := CleanText(autoReply), "* 仅你和对方可见\n你好,我在出差中,消息回复可能不及时。"; got != want {
t.Fatalf("auto-reply cleaned = %q, want %q", got, want)
}
// P1 regression: ordinary text whose middle line is a JSON fragment (no
// rich-content block anywhere) must be returned VERBATIM, not rewritten.
mixed := "payload:\n{\"approved\":false}\nplease check"
if got := CleanText(mixed); got != mixed {
t.Fatalf("mixed text was rewritten: got %q, want %q", got, mixed)
}
// An ordinary JSON line must also survive when a different line contains a
// recognised rich-content block. Card mode is not permission to discard
// unrelated user-authored JSON.
richAndPlain := `[{"items":[{"data":{"text":"卡片正文"}}]}]` + "\n" +
`{"approved":false}`
if got, want := CleanText(richAndPlain), "卡片正文\n{\"approved\":false}"; got != want {
t.Fatalf("mixed rich/plain JSON was rewritten: got %q, want %q", got, want)
}
// Malformed items (non-map item, item whose "data" isn't a map) are skipped;
// only the well-formed item's text is extracted.
blob := `[{"items":["notmap",{"data":"notmap"},{"data":{"text":"有效正文"}}]}]`
if got := CleanText(blob); got != "有效正文" {
t.Fatalf("CleanText rich edge = %q, want 有效正文", got)
}
tests := map[string]string{
"上周五 7.1 KW": "上周五 7.1 KW",
"上周客户统计的[图片消息](mediaId=@lQ)": "上周客户统计的[图片消息](mediaId=@lQ)",
"[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载": "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载",
"[讨论] 排期\n明天开会": "[讨论] 排期\n明天开会",
// a lone JSON object that isn't a rich-content block is left untouched
`{"autoLayout":false,"enableForward":false}`: `{"autoLayout":false,"enableForward":false}`,
}
for in, want := range tests {
if got := CleanText(in); got != want {
t.Errorf("CleanText(%q) = %q, want %q", in, got, want)
}
}
}
func TestIsEncryptedAndMarker(t *testing.T) {
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
"||2||1||196"
if !IsEncrypted(cipher) {
t.Fatalf("ciphertext not detected: %q", cipher)
}
if got := CleanText(cipher); !strings.Contains(got, "加密消息") || strings.Contains(got, "||2||1||") {
t.Fatalf("encrypted cleaned = %q, want marker not ciphertext", got)
}
for _, s := range []string{
"上周五 7.1 KW",
"价格 100||2||1||3",
"[图片消息](mediaId=@lQLPJwDw3VmNDcfMos0DhLB3OHPQeTBlzgov2Oi1ly4A)",
"大哥,我看了一下我觉得有几个点可以关注一下",
strings.Repeat("好", 20) + "||2||1||1", // long CJK body + trailer, not base64
} {
if IsEncrypted(s) {
t.Errorf("false positive: %q flagged as encrypted", s)
}
}
}
func TestText(t *testing.T) {
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
t.Errorf("Text string = %v", got)
}
if got := Text(map[string]any{"content": map[string]any{"text": "嵌套"}}); got != "嵌套" {
t.Errorf("Text nested = %v", got)
}
if got := Text(map[string]any{"plainText": "纯文本"}); got != "纯文本" {
t.Errorf("Text plainText = %v", got)
}
if got := Text(map[string]any{"foo": 1}); got != nil {
t.Errorf("Text none = %v, want nil", got)
}
}
func TestCreateTime(t *testing.T) {
if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" {
t.Errorf("CreateTime = %v", got)
}
if got := CreateTime(map[string]any{}); got != nil {
t.Errorf("CreateTime empty = %v, want nil", got)
}
}
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
row := map[string]any{"text": Text(m)}
if fwd := Forwarded(m, project); len(fwd) > 0 { // recurse
row["forwarded"] = fwd
}
return row
}
if got := Forwarded(map[string]any{"content": "x"}, project); got != nil {
t.Errorf("Forwarded none = %v", got)
}
fwd := Forwarded(map[string]any{
"forwardMessages": []any{
map[string]any{"content": "a"},
"not-a-map",
map[string]any{"content": "b", "forwardMessages": []any{
map[string]any{"content": "nested"},
}},
},
}, project)
if len(fwd) != 2 || fwd[0]["text"] != "a" || fwd[1]["text"] != "b" {
t.Fatalf("Forwarded = %#v", fwd)
}
nested, ok := fwd[1]["forwarded"].([]map[string]any)
if !ok || len(nested) != 1 || nested[0]["text"] != "nested" {
t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"])
}
}
+11
View File
@@ -41,6 +41,17 @@ func Commands() []*cobra.Command {
return build(allShortcuts)
}
// BuiltInCommands compiles only distribution-owned shortcuts.
func BuiltInCommands() []*cobra.Command {
builtins := make([]Shortcut, 0, len(allShortcuts))
for _, registered := range allShortcuts {
if !registered.UserDefined {
builtins = append(builtins, registered)
}
}
return build(builtins)
}
// All returns the registered shortcuts. Primarily for coverage tests that need
// each shortcut's declared flags (types/enums/required) to synthesize inputs.
func All() []Shortcut {
+20
View File
@@ -266,6 +266,26 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
}
}
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
previous := append([]Shortcut(nil), allShortcuts...)
t.Cleanup(func() { allShortcuts = previous })
allShortcuts = nil
Register(
Shortcut{Service: "calendar", Command: "+builtin", Execute: noop},
Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop},
)
all := Commands()
if len(all) != 1 || len(all[0].Commands()) != 2 {
t.Fatalf("all shortcut commands = %#v", all)
}
builtins := BuiltInCommands()
if len(builtins) != 1 || len(builtins[0].Commands()) != 1 ||
builtins[0].Commands()[0].Name() != "+builtin" {
t.Fatalf("built-in shortcut commands = %#v", builtins)
}
}
func noop(_ *RuntimeContext) error { return nil }
func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
+41 -11
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
@@ -91,12 +92,7 @@ var AtMe = shortcut.Shortcut{
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": atMeSender(m),
"time": atMeTime(m),
"text": atMeText(m),
"conversation": atMeConversation(m),
})
results = append(results, atMeProject(m))
}
return rt.Output(map[string]any{"messages": results})
},
@@ -194,28 +190,62 @@ func atMeToMaps(arr []any) []map[string]any {
return out
}
// atMeProject reshapes one @me message into {sender, time, text, conversation},
// running text through the shared chatmsg cleaning (card/auto-reply JSON →
// readable, ciphertext → marker) and recursively expanding any forwarded chat
// record under "forwarded".
func atMeProject(m map[string]any) map[string]any {
row := map[string]any{
"sender": atMeSender(m),
"time": atMeTime(m),
"text": atMeCleanText(m),
"conversation": atMeConversation(m),
}
if forwarded := chatmsg.Forwarded(m, atMeProject); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// atMeCleanText runs atMeText's extraction through chatmsg.CleanText so
// card/auto-reply JSON and ciphertext render readable instead of leaking raw.
func atMeCleanText(m map[string]any) any {
if s, ok := atMeText(m).(string); ok {
return chatmsg.CleanText(s)
}
return atMeText(m)
}
// atMeSender reads a message's sender display name/id, tolerating the common
// sender keys the gateway may use (including a nested sender object).
// sender keys the gateway may use (including a nested sender object). The literal
// string "null" (carried by forwarded sub-messages) and the empty string are
// both treated as absent so they never surface as the speaker.
func atMeSender(m map[string]any) any {
norm := func(v any) string {
if s := atMeString(v); s != "" && s != "null" {
return s
}
return ""
}
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := atMeString(nested[k2]); v != "" {
if v := norm(nested[k2]); v != "" {
return v
}
}
}
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
@@ -0,0 +1,131 @@
// 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 smart
import (
"strings"
"testing"
)
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestAtMeProject(t *testing.T) {
// nested sender object + plain text
row := atMeProject(map[string]any{
"sender": map[string]any{"name": "念晨"},
"createTime": "2026-07-19 13:37:03",
"content": "普通消息",
"conversationTitle": "群A",
"openConversationId": "cid1",
})
if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" {
t.Fatalf("atMeProject nested = %#v", row)
}
// encrypted content → marked (never leaked); id-only sender fallback; a
// forwarded sub-message whose sender is the literal "null" must be nulled.
row = atMeProject(map[string]any{
"senderId": "DXYZ",
"openMessageId": "m1",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "子消息", "createTime": "t"},
},
})
if row["sender"] != "DXYZ" {
t.Errorf("atMeProject id-fallback sender = %v", row["sender"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
t.Errorf("atMeProject encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 {
t.Fatalf("atMeProject forwarded = %#v", row["forwarded"])
}
if fwd[0]["sender"] != nil {
t.Errorf("forwarded sub sender = %v, want nil (literal \"null\")", fwd[0]["sender"])
}
// no sender / no text at all → nils, no forwarded key
row = atMeProject(map[string]any{"createTime": "t"})
if row["sender"] != nil || row["text"] != nil {
t.Errorf("atMeProject empty = %#v", row)
}
if _, has := row["forwarded"]; has {
t.Errorf("atMeProject plain unexpectedly has forwarded")
}
}
func TestSearchMsgProject(t *testing.T) {
// nested sender + plain text + messageId
row := searchMsgProject(map[string]any{
"sender": map[string]any{"nick": "千启"},
"createTime": "2026-07-19 13:37:03",
"content": "命中关键词的消息",
"msgId": "mid1",
})
if row["sender"] != "千启" || row["text"] != "命中关键词的消息" {
t.Fatalf("searchMsgProject = %#v", row)
}
// encrypted → marker; id-only sender; forwarded "null" sender nulled.
row = searchMsgProject(map[string]any{
"senderId": "DAAA",
"openMessageId": "m2",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "转发子消息", "createTime": "t"},
},
})
if row["sender"] != "DAAA" {
t.Errorf("searchMsgProject sender = %v", row["sender"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil {
t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"])
}
// no sender / no text
row = searchMsgProject(map[string]any{"createTime": "t"})
if row["sender"] != nil || row["text"] != nil {
t.Errorf("searchMsgProject empty = %#v", row)
}
}
// TestSenderHelpers exercises the atMe/searchMsg sender key families directly:
// a senderName-family key (first probe loop), a flat string under "sender"
// (second loop), and the "null" sentinel normalisation.
func TestSenderHelpers(t *testing.T) {
cases := []struct {
fn func(map[string]any) any
name string
}{
{atMeSender, "atMeSender"},
{searchMsgSender, "searchMsgSender"},
}
for _, c := range cases {
if got := c.fn(map[string]any{"senderName": "张三"}); got != "张三" {
t.Errorf("%s senderName = %v, want 张三", c.name, got)
}
if got := c.fn(map[string]any{"sender": "李四"}); got != "李四" {
t.Errorf("%s flat sender = %v, want 李四", c.name, got)
}
if got := c.fn(map[string]any{"senderName": "null"}); got != nil {
t.Errorf("%s \"null\" = %v, want nil", c.name, got)
}
}
}
+14 -50
View File
@@ -17,6 +17,7 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// ChatMessages: fetch the message list of one conversation (group OR single
@@ -111,11 +112,7 @@ var ChatMessages = shortcut.Shortcut{
items := chatMessageItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": chatMessageSender(m),
"text": chatMessageText(m),
"createTime": chatMessageCreateTime(m),
})
results = append(results, projectChatMessage(m))
}
return rt.Output(map[string]any{
@@ -156,53 +153,20 @@ func chatMessageItems(data map[string]any) []map[string]any {
return nil
}
// chatMessageSender reads a message's speaker display name, tolerating common
// sender-name keys.
func chatMessageSender(m map[string]any) any {
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok && s == "" {
continue
}
return v
}
// projectChatMessage reshapes one raw message into the clean
// {sender, text, createTime} projection, rendering card/auto-reply JSON and
// marking encrypted messages via chatmsg, and recursively expanding forwarded
// chat records under "forwarded".
func projectChatMessage(m map[string]any) map[string]any {
row := map[string]any{
"sender": chatmsg.Sender(m),
"text": chatmsg.Text(m),
"createTime": chatmsg.CreateTime(m),
}
return nil
}
// chatMessageText reads a message's textual content, tolerating common text
// keys and one level of nesting (e.g. {"content":{"text":"..."}}).
func chatMessageText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return t
}
case map[string]any:
for _, inner := range []string{"text", "content", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return s
}
}
}
if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return nil
}
// chatMessageCreateTime reads a message's create/send time, returning the raw
// value under whichever candidate key is present.
func chatMessageCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
return row
}
func init() {
@@ -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 smart
import "testing"
// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record
// ("聊天记录") exposes its nested messages under "forwarded" instead of
// collapsing to the lossy top-level "[卡片]" summary, recursing through nested
// forwards, and that the string-"null" sender is nulled out. The per-field
// behaviour (sender/text/encryption) is covered in the chatmsg package tests.
func TestProjectChatMessageExpandsForwarded(t *testing.T) {
row := projectChatMessage(map[string]any{
"sender": "hugozhu",
"content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]",
"createTime": "2026-07-20 21:41:21",
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"},
map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40",
// nested forward inside a forward — must expand recursively.
"forwardMessages": []any{
map[string]any{"sender": "念晨", "content": "收到", "createTime": "2026-07-19 23:36:00"},
},
},
},
})
if row["sender"] != "hugozhu" {
t.Fatalf("top sender = %v, want hugozhu", row["sender"])
}
forwarded, ok := row["forwarded"].([]map[string]any)
if !ok || len(forwarded) != 2 {
t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"])
}
if forwarded[0]["sender"] != nil {
t.Errorf("forwarded[0].sender = %v, want nil (string \"null\")", forwarded[0]["sender"])
}
if forwarded[0]["text"] != "读下冬翔发给我的最近两条消息" {
t.Errorf("forwarded[0].text = %v", forwarded[0]["text"])
}
nested, ok := forwarded[1]["forwarded"].([]map[string]any)
if !ok || len(nested) != 1 || nested[0]["sender"] != "念晨" {
t.Errorf("nested forwarded = %#v, want 1 entry from 念晨", forwarded[1]["forwarded"])
}
// A plain message must not grow a "forwarded" key.
plain := projectChatMessage(map[string]any{"sender": "念晨", "content": "hi", "createTime": "t"})
if _, has := plain["forwarded"]; has {
t.Errorf("plain message unexpectedly has forwarded key: %#v", plain)
}
}
+40 -11
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// SearchMsg: search messages inside a single group chat by keyword in one step.
@@ -108,12 +109,7 @@ var SearchMsg = shortcut.Shortcut{
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": searchMsgSender(m),
"time": searchMsgTime(m),
"text": searchMsgText(m),
"messageId": searchMsgMessageID(m),
})
results = append(results, searchMsgProject(m))
}
return rt.Output(map[string]any{"messages": results})
},
@@ -152,28 +148,61 @@ func searchMsgToMaps(arr []any) []map[string]any {
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
// chat record under "forwarded".
func searchMsgProject(m map[string]any) map[string]any {
row := map[string]any{
"sender": searchMsgSender(m),
"time": searchMsgTime(m),
"text": searchMsgCleanText(m),
"messageId": searchMsgMessageID(m),
}
if forwarded := chatmsg.Forwarded(m, searchMsgProject); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText.
func searchMsgCleanText(m map[string]any) any {
if s, ok := searchMsgText(m).(string); ok {
return chatmsg.CleanText(s)
}
return searchMsgText(m)
}
// searchMsgSender reads a message's sender display name/id, tolerating the
// common sender keys the gateway may use (including a nested sender object).
// common sender keys the gateway may use (including a nested sender object). The
// literal string "null" (carried by forwarded sub-messages) and the empty string
// are both treated as absent so they never surface as the speaker.
func searchMsgSender(m map[string]any) any {
norm := func(v any) string {
if s := searchMsgString(v); s != "" && s != "null" {
return s
}
return ""
}
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := searchMsgString(nested[k2]); v != "" {
if v := norm(nested[k2]); v != "" {
return v
}
}
}
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
+1 -54
View File
@@ -78,11 +78,7 @@ var ThreadReplies = shortcut.Shortcut{
items := threadReplyItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": threadReplySender(m),
"text": threadReplyText(m),
"createTime": threadReplyCreateTime(m),
})
results = append(results, projectChatMessage(m))
}
return rt.Output(map[string]any{
@@ -124,55 +120,6 @@ func threadReplyItems(data map[string]any) []map[string]any {
return nil
}
// threadReplySender reads a reply's speaker display name, tolerating common
// sender-name keys.
func threadReplySender(m map[string]any) any {
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok && s == "" {
continue
}
return v
}
}
return nil
}
// threadReplyText reads a reply's textual content, tolerating common text keys
// and one level of nesting (e.g. {"text":{"content":"..."}}).
func threadReplyText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return t
}
case map[string]any:
for _, inner := range []string{"content", "text", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return s
}
}
}
}
return nil
}
// threadReplyCreateTime reads a reply's create/send time, returning the raw
// value under whichever candidate key is present.
func threadReplyCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
func init() {
shortcut.Register(ThreadReplies)
}
+4
View File
@@ -127,6 +127,10 @@ type Shortcut struct {
Tips []string
// Hidden hides the command from listings while keeping it invocable.
Hidden bool
// UserDefined identifies shortcuts loaded from the user's config
// directory. Distribution-owned Schema and interface snapshots exclude
// these runtime extensions even if another root loaded them earlier.
UserDefined bool
// Validate optionally checks resolved flag values before execution. Return a
// non-nil error to abort with a validation message. Runs after built-in
+1
View File
@@ -196,6 +196,7 @@ func Compile(s Spec) shortcut.Shortcut {
Intent: intent,
Risk: risk,
Flags: flags,
UserDefined: true,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
for key, tmpl := range bind {
+3
View File
@@ -69,6 +69,9 @@ func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) {
if sc.Service != "chat" || sc.Command != "+notify-team" {
t.Fatalf("bad identity: %+v", sc)
}
if !sc.UserDefined {
t.Fatal("compiled user shortcut is missing user-defined provenance")
}
if sc.Risk != shortcut.RiskRead {
t.Errorf("risk default = %q, want read", sc.Risk)
}
+19 -1
View File
@@ -23,15 +23,28 @@ const SourceAnnotation = "dws.source"
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
const SourceEnvelope = "envelope"
// SourcePlugin marks a command as an installed plugin extension. Plugin
// commands are part of the runtime CLI surface, not the embedded base Schema.
const SourcePlugin = "plugin"
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
func MarkEnvelopeSource(cmd *cobra.Command) {
markSource(cmd, SourceEnvelope)
}
// MarkPluginSource stamps cmd with installed-plugin provenance.
func MarkPluginSource(cmd *cobra.Command) {
markSource(cmd, SourcePlugin)
}
func markSource(cmd *cobra.Command, source string) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[SourceAnnotation] = SourceEnvelope
cmd.Annotations[SourceAnnotation] = source
}
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
@@ -40,6 +53,11 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// IsPluginSourced reports whether cmd came from an installed plugin.
func IsPluginSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin
}
// KindAnnotation is the annotation key for marking command kinds.
const KindAnnotation = "dws.kind"
+16
View File
@@ -43,3 +43,19 @@ func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
t.Parallel()
MarkEnvelopeSource(nil)
}
func TestPluginSourceProvenance(t *testing.T) {
t.Parallel()
if IsPluginSourced(nil) {
t.Fatal("nil command should not be plugin sourced")
}
cmd := &cobra.Command{Use: "conference"}
MarkPluginSource(cmd)
if !IsPluginSourced(cmd) || IsEnvelopeSourced(cmd) {
t.Fatalf("plugin source annotation = %#v", cmd.Annotations)
}
if got := cmd.Annotations[SourceAnnotation]; got != SourcePlugin {
t.Fatalf("SourceAnnotation = %q, want %q", got, SourcePlugin)
}
MarkPluginSource(nil)
}
+18 -2
View File
@@ -39,8 +39,24 @@ func defaultHooks() *Hooks {
base["claw-type"] = DefaultOSSClawType
return base
},
StaticServers: openStaticServers,
VisibleProducts: openVisibleProducts,
StaticServers: openStaticServers,
SupplementServers: openSupplementServers,
VisibleProducts: openVisibleProducts,
}
}
// openSupplementServers returns helper-only MCP endpoints owned by the open
// CLI. They are callable by explicit server ID but are deliberately excluded
// from VisibleProducts, so no top-level product command is generated. They
// stay separate from syncdata.StaticServers because only explicitly wired CLI
// helpers may call them; they are not public MCP product surfaces.
func openSupplementServers() []ServerInfo {
return []ServerInfo{
{
ID: "mcp-meta",
Name: "MCP 元服务",
Endpoint: "https://mcp-gw.dingtalk.com/server/89833ea5debf30c260a07ffcb5127ffa3bf0c830cd76babadb293d9861485d44",
},
}
}
+20
View File
@@ -70,4 +70,24 @@ func TestOpenVisibleProductsExcludesCompatibilityOnlyCommands(t *testing.T) {
t.Fatal("conference must remain compatibility-only and not be added to StaticServers")
}
}
if byID["mcp-meta"] {
t.Fatal("mcp-meta is helper-only and must not appear in VisibleProducts")
}
}
func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
servers := openSupplementServers()
for _, server := range servers {
if server.ID != "mcp-meta" {
continue
}
if server.Endpoint == "" {
t.Fatal("mcp-meta has empty endpoint")
}
if len(server.Prefixes) != 0 {
t.Fatal("mcp-meta must remain helper-only without command prefixes")
}
return
}
t.Fatal("openSupplementServers() missing mcp-meta")
}
+98 -3
View File
@@ -1,6 +1,10 @@
package mcptypes
import "encoding/json"
import (
"bytes"
"encoding/json"
"strings"
)
type ServerDescriptor struct {
Key string
@@ -16,19 +20,110 @@ type ServerDescriptor struct {
type CLIOverlay struct {
ID string `json:"id"`
Command string `json:"command"`
Parent string `json:"parent,omitempty"`
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases"`
Prefixes []string `json:"prefixes"`
Group string `json:"group,omitempty"`
Skip bool `json:"skip"`
Hidden bool `json:"hidden,omitempty"`
Tools []CLITool `json:"tools"`
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
ServerDeps []string `json:"serverDeps,omitempty"`
Hints map[string]json.RawMessage `json:"hintCommands,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
}
type CLIGroupDef struct {
Description string `json:"description,omitempty"`
}
type CLITool struct {
Name string `json:"name"`
Name string `json:"name"`
CLIName string `json:"cliName,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
IsSensitive bool `json:"isSensitive,omitempty"`
Category string `json:"category,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Flags map[string]CLIFlagHint `json:"flags,omitempty"`
}
type CLIFlagHint struct {
Shorthand string `json:"shorthand,omitempty"`
Alias string `json:"alias,omitempty"`
}
type CLIToolOverride struct {
ServerOverride string `json:"serverOverride,omitempty"`
CLIName string `json:"cliName,omitempty"`
CLIAliases []string `json:"cliAliases,omitempty"`
Description string `json:"description,omitempty"`
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 map[string]any `json:"outputFormat,omitempty"`
ServerOverride string `json:"serverOverride,omitempty"`
BodyWrapper string `json:"bodyWrapper,omitempty"`
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
RequireTogether [][]string `json:"requireTogether,omitempty"`
RejectPositional bool `json:"rejectPositional,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
Pipeline []json.RawMessage `json:"pipeline,omitempty"`
}
type CLIFlagOverride struct {
Alias string `json:"alias,omitempty"`
Aliases []string `json:"aliases,omitempty"`
MapsTo string `json:"mapsTo,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 string `json:"shorthand,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
Positional bool `json:"positional,omitempty"`
PositionalIndex int `json:"positionalIndex,omitempty"`
Type string `json:"type,omitempty"`
OmitWhen string `json:"omitWhen,omitempty"`
RuntimeDefault string `json:"runtimeDefault,omitempty"`
PipelineLocal bool `json:"pipelineLocal,omitempty"`
}
func (override *CLIFlagOverride) UnmarshalJSON(data []byte) error {
type alias CLIFlagOverride
aux := struct {
Default json.RawMessage `json:"default,omitempty"`
*alias
}{alias: (*alias)(override)}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&aux); err != nil {
return err
}
override.Default = coercePluginScalar(aux.Default)
return nil
}
func coercePluginScalar(raw json.RawMessage) string {
value := strings.TrimSpace(string(raw))
if value == "" || value == "null" ||
strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
return ""
}
if strings.HasPrefix(value, `"`) {
var decoded string
if json.Unmarshal(raw, &decoded) == nil {
return decoded
}
return ""
}
return value
}
func OverlayFromJSON(data json.RawMessage) CLIOverlay {
+70
View File
@@ -0,0 +1,70 @@
package mcptypes
import (
"encoding/json"
"testing"
)
func TestCLIFlagOverrideUnmarshalJSONCoercesScalarDefaults(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "missing", raw: `{}`, want: ""},
{name: "null", raw: `{"default":null}`, want: ""},
{name: "object", raw: `{"default":{"nested":true}}`, want: ""},
{name: "array", raw: `{"default":[1,2]}`, want: ""},
{name: "string", raw: `{"default":"hello"}`, want: "hello"},
{name: "escaped string", raw: `{"default":"line\nvalue"}`, want: "line\nvalue"},
{name: "boolean", raw: `{"default":true}`, want: "true"},
{name: "integer", raw: `{"default":42}`, want: "42"},
{name: "number", raw: `{"default":-1.25}`, want: "-1.25"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got CLIFlagOverride
if err := json.Unmarshal([]byte(tt.raw), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Default != tt.want {
t.Fatalf("Default = %q, want %q", got.Default, tt.want)
}
})
}
var got CLIFlagOverride
if err := json.Unmarshal([]byte(`{"alias":`), &got); err == nil {
t.Fatal("json.Unmarshal() error = nil, want malformed JSON error")
}
if err := got.UnmarshalJSON([]byte(`{"alias":`)); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() error = nil, want malformed JSON error")
}
if err := json.Unmarshal([]byte(`{"unknownFlagField":true}`), &got); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() accepted an unknown field")
}
if got := coercePluginScalar(json.RawMessage(`"unterminated`)); got != "" {
t.Fatalf("coercePluginScalar(invalid string) = %q, want empty", got)
}
}
func TestOverlayFromJSONHandlesEmptyValidAndMalformedInput(t *testing.T) {
if got := OverlayFromJSON(nil); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(nil) = %#v, want zero overlay", got)
}
valid := json.RawMessage(`{
"id":"conference",
"command":"meeting",
"toolOverrides":{"create":{"flags":{"count":{"default":3}}}}
}`)
got := OverlayFromJSON(valid)
if got.ID != "conference" || got.Command != "meeting" ||
got.ToolOverrides["create"].Flags["count"].Default != "3" {
t.Fatalf("OverlayFromJSON(valid) = %#v", got)
}
if got := OverlayFromJSON(json.RawMessage(`{`)); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(malformed) = %#v, want zero overlay", got)
}
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more