Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf5b76de07 | ||
|
|
423e16ced0 | ||
|
|
c1a4bd6781 | ||
|
|
02817bc043 | ||
|
|
9f76c1844a | ||
|
|
857d9b8c1b | ||
|
|
a7fdcea086 | ||
|
|
9fc63b6405 | ||
|
|
3233e1fe93 | ||
|
|
f7e61feacf | ||
|
|
cdc3fbe328 | ||
|
|
b4ea1f168d | ||
|
|
b03017997d | ||
|
|
902e084d8a | ||
|
|
412e77f215 | ||
|
|
2a0bf1ebea | ||
|
|
9ce13da6ed | ||
|
|
0e5731166b | ||
|
|
92edd8ea53 | ||
|
|
931d75beaf | ||
|
|
7667cb30a3 | ||
|
|
015daae064 | ||
|
|
e7510ea5f0 | ||
|
|
582b73cb40 | ||
|
|
04ea184ff6 | ||
|
|
0908b2ca6e | ||
|
|
070febd7bf | ||
|
|
e3782231be | ||
|
|
167a547a65 | ||
|
|
8f62c19104 | ||
|
|
82798dc7fc | ||
|
|
3319cf62d5 | ||
|
|
1626818a98 | ||
|
|
a03d6ebacc | ||
|
|
4ee4a44e16 | ||
|
|
40181f8c0c | ||
|
|
14ff02ebe1 | ||
|
|
55574fe12e | ||
|
|
222ee16d51 | ||
|
|
27ced3ee18 | ||
|
|
129e8a10ef | ||
|
|
02fba09c1e | ||
|
|
94b64f74ac | ||
|
|
cefcf5b409 | ||
|
|
441289cdfe | ||
|
|
d03823d772 | ||
|
|
c16a377863 | ||
|
|
31c3acc94b | ||
|
|
f7e702df8d | ||
|
|
7fd40ea19a | ||
|
|
bee246e62c | ||
|
|
089caa92a8 | ||
|
|
2f0f32f56f | ||
|
|
068d9ff2f5 | ||
|
|
21cd3f8bc4 | ||
|
|
418928b9a5 | ||
|
|
b047b2c3c9 | ||
|
|
a516e5f54a | ||
|
|
70e4e75c66 | ||
|
|
1116916b24 | ||
|
|
c0c81b4d70 | ||
|
|
eedc41ac54 | ||
|
|
c14e24569c | ||
|
|
8add2c00cf | ||
|
|
29dceec5ce | ||
|
|
b78a0dee47 | ||
|
|
807191396e | ||
|
|
e15a2c4efb | ||
|
|
6c0cf3438b | ||
|
|
e3f30420fb |
@@ -1047,9 +1047,7 @@ jobs:
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
- name: Verify final release artifacts
|
||||
run: |
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
|
||||
- name: Verify npm package before sealing GitHub Release
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -1174,9 +1172,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Verify finalized release artifacts before publication
|
||||
run: |
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
|
||||
- name: Verify npm package before sealing GitHub Release
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -1272,7 +1268,7 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||
"$RELEASE_VERSION" "$published_dir"
|
||||
DWS_PACKAGE_DIST_DIR="$published_dir" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
|
||||
if [ "$IS_RECOVERY" = "true" ] && ! cmp -s "$asset" "dist/$(basename "$asset")"; then
|
||||
echo "Public recovery asset differs from this run's sealed artifact: $(basename "$asset")" >&2
|
||||
@@ -1378,7 +1374,7 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||
"$RELEASE_VERSION" "$sealed_upload"
|
||||
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
|
||||
local_asset="dist/$(basename "$remote_asset")"
|
||||
cmp -s "$local_asset" "$remote_asset" || {
|
||||
@@ -1448,7 +1444,7 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||
"$RELEASE_VERSION" "$immutable_dir"
|
||||
DWS_PACKAGE_DIST_DIR="$immutable_dir" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do
|
||||
sealed_asset="dist/$(basename "$immutable_asset")"
|
||||
cmp -s "$sealed_asset" "$immutable_asset" || {
|
||||
@@ -1570,8 +1566,7 @@ jobs:
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION"
|
||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
|
||||
@@ -1740,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:
|
||||
@@ -1757,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: |
|
||||
@@ -1781,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 }}
|
||||
@@ -2185,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
|
||||
@@ -2199,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 }}
|
||||
@@ -2213,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:
|
||||
@@ -2335,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 }}
|
||||
@@ -2357,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 }}
|
||||
@@ -2373,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 }}
|
||||
@@ -2390,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"
|
||||
|
||||
+12
@@ -54,3 +54,15 @@ test/dev_functional/results.jsonl
|
||||
/coverage-policy.txt
|
||||
/coverage.html
|
||||
dwsbin
|
||||
|
||||
# Local shortcut eval / real-backend capture artifacts — may contain real PII
|
||||
# (employee names/emails, userIds, conversation & message IDs). Never commit.
|
||||
/docs/shortcut-real-read-results.json
|
||||
/docs/shortcut-real-write-results.json
|
||||
/docs/shortcut-comparison.html
|
||||
/docs/shortcut-gsb-eval.*
|
||||
/scripts/run_shortcut_real_read_matrix.py
|
||||
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
+53
-2
@@ -6,13 +6,62 @@ 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
|
||||
|
||||
- **Relaxed stable promotion contract** — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window.
|
||||
- **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 sealed `v1.0.53-beta.7` contents 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.
|
||||
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.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,12 +74,14 @@ This release promotes the sealed `v1.0.53-beta.7` contents to stable. It adds en
|
||||
|
||||
- **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope.
|
||||
- **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked.
|
||||
- **Relaxed stable promotion contract** (#729) — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles.
|
||||
- **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size.
|
||||
- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags.
|
||||
- **Package-manager version verification** (#735) — npm-vendored, Homebrew-installed, and packaged release binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed.
|
||||
|
||||
## [1.0.53-beta.7] - 2026-07-21
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 元服务返回空结果")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 连接地址"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user