Compare commits

...
Author SHA1 Message Date
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
修雨 b794d802f2 release: prepare 1.0.49 stable (#574) 2026-07-08 00:14:57 +08:00
修雨 e6c1dfe15c Merge pull request #570 from DingTalk-Real-AI/fix/release-publish-unblock
ci: unblock npm release from Gitee mirror
2026-07-07 23:46:21 +08:00
修雨 32d32cd827 Merge pull request #572 from audanye-sudo/fix/qa-optimize-6products
fix: resolve real-machine QA findings across CLI, scripts and skill docs
2026-07-07 23:44:00 +08:00
qinze a65d6f23ec fix: resolve real-machine QA findings across CLI, scripts and skill docs
Fix CLI command bugs surfaced by full real-machine QA:
- aitable: make chart/dashboard share update --enabled a string flag so
  "--enabled false" disables instead of silently enabling (bool flag +
  space-syntax help example inverted the action); clarify chart update
  requires --config; make form get filter by view-id client-side so it
  returns a single form; drop inline // comments from chart JSON examples
- chat: resolve conversation-info --user to openDingTalkId, register
  --id/--conversation-id/--chat aliases; cap list-all-conversations
  --limit at 100 and reject larger values instead of silent truncation;
  detect webhook errcode failures instead of wrapping them as success;
  remove duplicate group/members subcommand registration in help
- contact: register --dept/--depts as the primary dept flags to match
  the RunE parsing (were only registered as --id/--ids)
- sheet: emit clean JSON for media-upload and export under --format json,
  suppressing progress lines that leaked onto stdout
- wiki: correct node create --type enum (drop unsupported asheet, add
  axls/able/appt/adraw/amind)
- ding: default message list --type to ALL since the server rejects an
  empty type

Fix helper scripts (mono and multi):
- aitable import/export flag names and tableId length regex
- mail search --limit, contact dept response keys and userInfo nesting
- attendance_my_record whoami compatibility, calendar_schedule_meeting
  event id unwrapping, drive_tree_list recursion via fileId, report
  scripts migrated off deprecated report list/detail

Sync skill docs (mono and multi) to real-machine behavior across all
products: command indexes, flag names, enums, return-structure keys, and
cross-product intent routing; annotate genuinely server-side limitations
and the no-op global --jq/--fields flags.
2026-07-07 23:38:23 +08:00
修雨 a838ae75a7 ci: unblock npm release from Gitee mirror 2026-07-07 22:23:16 +08:00
修雨 238f4256d3 ci: harden Gitee mirror synchronization
Serialize Gitee mirror runs, mirror tag events without touching main, and align Gitee release tags before uploading assets.
2026-07-07 20:48:40 +08:00
修雨 b83e6dc239 release: prepare 1.0.48 stable 2026-07-07 20:48:39 +08:00
修雨 a842560d71 ci: harden gitee mirror synchronization 2026-07-07 20:15:50 +08:00
修雨 d808843f75 feat: seal remove-discovery delivery beta
Merge sealed remove-discovery delivery beta with static endpoint runtime, legacy compatibility aliases, synced skills, yolo connect default, and beta upgrade track.
2026-07-07 19:19:20 +08:00
修雨 6623a6969d docs: sync skills and beta release guidance 2026-07-07 19:15:29 +08:00
修雨 a32d7985e6 refactor: switch to static endpoint delivery runtime 2026-07-07 19:07:03 +08:00
修雨 d3f8e9d712 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon (#558)
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 17:21:44 +08:00
470 changed files with 62739 additions and 62107 deletions
+12
View File
@@ -16,6 +16,10 @@ on:
- cron: '0 18 * * *'
workflow_dispatch:
concurrency:
group: gitee-code-mirror
cancel-in-progress: false
jobs:
mirror:
runs-on: ubuntu-latest
@@ -36,6 +40,14 @@ jobs:
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
+71
View File
@@ -0,0 +1,71 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+82 -4
View File
@@ -5,12 +5,18 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
repair_npm_version:
description: "Only publish an existing release to npm, e.g. v1.0.48"
required: false
type: string
permissions:
contents: write
jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
runs-on: ubuntu-latest
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
@@ -76,26 +82,98 @@ jobs:
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
# 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
timeout-minutes: 20
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
repair-npm:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.repair_npm_version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.repair_npm_version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -46,4 +46,5 @@ jobs:
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+51
View File
@@ -0,0 +1,51 @@
name: Gitee Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release tag to build on Gitee, e.g. v1.0.48"
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install packaging tools
run: |
set -eu
sudo apt-get update
sudo apt-get install -y zip unzip curl
- name: Install rcodesign
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Build and publish Gitee release
env:
VERSION: ${{ inputs.version || github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
run: ./scripts/release/build-and-publish-gitee.sh
+53
View File
@@ -6,6 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.50] - 2026-07-08
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
### Fixed
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
### Changed
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
## [1.0.49] - 2026-07-08
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
### Fixed
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
### Changed
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
## [1.0.48] - 2026-07-07
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
### Changed
- **Remove-discovery delivery is now formal/stable** — the beta validation line is ready to cut as `v1.0.48`; normal stable channels (`dws upgrade`, GitHub `releases/latest`, install scripts, and npm `latest`) should receive this release after the official tag is published.
- **Static endpoint runtime sealed for stable delivery** — the open edition no longer depends on dynamic service discovery at runtime, while preserving legacy command compatibility aliases and the synced help/skill surface from the beta.
- **`contact label` is restored as real wukong-compatible functionality** — `dws contact label list/get/list-members` now call `get_org_labels`, `search_label_by_name`, and `get_label_members_by_labelId`; `contact role` remains an alias, and the common top-level compatibility entries (`contact search/find/list/get/self/me/whoami/get-self`) now dispatch to real user/dept/label tools where unambiguous.
- **Skill docs match the sealed command surface** — contact docs again describe the real `contact label` three-step role lookup flow; video-conference start/invite/share flows remain explicitly unsupported and point users to the DingTalk client.
### Fixed
- **`calendar event list --dry-run` no longer executes the real list call** — the sorted event-list wrapper now respects dry-run and prints the `list_calendar_events` preview instead of calling the backend.
- **`chat file upload` is downlined** — the hidden compatibility entry now returns a clear downline message and never calls `chat/upload_conversation_file_by_url`; the supported file path remains `chat message send --msg-type file --file-path`.
- **Optional plugin version validation no longer pollutes every command** — incompatible local plugins such as conference are skipped at debug level during command-tree construction instead of printing a WARN on unrelated commands.
- **PR #45 review follow-ups are folded into the release** — doc version rollback pagination now unwraps nested result/content/data envelopes for `nextCursor`, mail helper scripts handle `{result:{emailAccounts:[...]}}`, and the generated attendance `.xlsx` fixture is removed from the skill scripts.
### Tests
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
## [1.0.47] - 2026-07-05
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
+27 -21
View File
@@ -109,6 +109,10 @@ go build -o dws ./cmd # build to current directory
cp dws ~/.local/bin/ # install to PATH
```
Static endpoint data is generated from the Wukong baseline and committed in this
repository under `internal/syncdata`, so source builds do not require a sibling
data checkout.
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
</details>
@@ -152,12 +156,18 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
```bash
dws upgrade # interactive upgrade to latest version
dws upgrade --check # check for new versions without installing
dws upgrade --list # list all available versions
dws upgrade --list # list stable release versions
dws upgrade --beta # upgrade to the latest beta pre-release
dws upgrade --check --beta # check the beta track without installing
dws upgrade --list --beta # list beta pre-release versions
dws upgrade --version v1.0.7 # upgrade to a specific version
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
dws upgrade --rollback # rollback to the previous version
dws upgrade -y # skip confirmation prompt
```
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
<details>
<summary><strong>How it works</strong></summary>
@@ -171,8 +181,9 @@ A backup of the current version is automatically created before each upgrade. Us
| Flag | Description |
|------|-------------|
| `--check` | Check for updates without installing |
| `--list` | List all available versions with changelogs |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
| `--list` | List available stable release versions with changelogs |
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
| `--rollback` | Rollback to the previous backed-up version |
| `--force` | Force reinstall even if already on the latest version |
| `--skip-skills` | Skip skill package update |
@@ -300,21 +311,18 @@ dws contact user search --query "engineering" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### Schema Discovery
### Command Help and Schema
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
Product commands are compiled into the binary in static endpoint mode. Use `--help` and the bundled Agent Skills as the source of truth; `dws schema` is retained for helper-only schemas such as `dev.*`.
```bash
# Step 1: Discover all available products
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# Inspect the current compiled command surface
dws aitable record query --help
# Step 2: Inspect target tool's parameter schema
dws schema aitable.query_records --jq '.tool.parameters'
# Helper-only schema introspection
dws schema "dev app create"
# Optional: inspect DingTalk authorization metadata for PAT planning
dws schema aitable.query_records --jq '.tool.auth'
# Step 3: Construct the correct call
# Construct the call
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
@@ -479,7 +487,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
dws schema "dev app create" --jq '.tool.required'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -488,14 +496,12 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
<summary><strong>Schema Introspection</strong> — helper-only schemas in static endpoint mode</summary>
```bash
dws schema # list all products and tools
dws schema aitable.query_records # view parameter schema
dws schema aitable.query_records --jq '.tool.required' # view required fields
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
dws schema --jq '.products[].id' # extract all product IDs
dws schema # static endpoint mode note
dws schema "dev app create" # view helper-only schema
dws schema "dev app create" --jq '.tool.required' # view required fields
```
</details>
@@ -626,7 +632,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
- [Changelog](./CHANGELOG.md) — release history and migration notes
+24 -17
View File
@@ -110,6 +110,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
</details>
@@ -152,12 +153,18 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
```bash
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --list # 列出正式 release 版本
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
dws upgrade --list --beta # 列出 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级
```
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
<details>
<summary><strong>工作原理</strong></summary>
@@ -171,8 +178,9 @@ dws upgrade -y # 跳过确认直接升级
| Flag | 说明 |
|------|------|
| `--check` | 仅检查更新,不安装 |
| `--list` | 列出所有可用版本及更新日志 |
| `--version` | 升级到指定版本(如 `v1.0.7`) |
| `--list` | 列出正式 release 版本及更新日志 |
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
| `--rollback` | 回滚到上一个备份版本 |
| `--force` | 强制重新安装,即使已是最新版本 |
| `--skip-skills` | 跳过技能包更新 |
@@ -300,18 +308,18 @@ dws contact user search --query "张三" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### Schema 发现
### 命令帮助与 Schema
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
产品命令在静态端点模式下已经编译进二进制。Agent 以 `--help` 和内置 Skill 为事实源;`dws schema` 仅保留给 `dev.*` 等 helper-only schema 查询。
```bash
# 第一步:发现所有可用产品
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# 查看当前编译出的命令面
dws aitable record query --help
# 第二步:查看目标工具的参数结构
dws schema aitable.query_records --jq '.tool.parameters'
# helper-only schema 自省
dws schema "dev app create"
# 第三步:构造正确的调用
# 构造正确的调用
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
@@ -476,7 +484,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
dws schema "dev app create" --jq '.tool.required'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -485,13 +493,12 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
<summary><strong>Schema 自省</strong> — 静态端点模式下的 helper-only schema</summary>
```bash
dws schema # 列出所有产品和工具
dws schema aitable.query_records # 查看参数 Schema
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
dws schema --jq '.products[].id' # 提取所有产品 ID
dws schema # 静态端点模式提示
dws schema "dev app create" # 查看 helper-only schema
dws schema "dev app create" --jq '.tool.required' # 查看必填字段
```
</details>
@@ -619,7 +626,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
+31 -16
View File
@@ -4,23 +4,38 @@
## High-Level Flow
1. `internal/market` fetches the registry and server metadata.
2. `internal/discovery` resolves runtime server capabilities and caches results.
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helper commands, and plugin commands.
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
## Repository Structure
- `cmd`: CLI entrypoint
- `internal/app`: root command wiring and static utility commands
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
- `internal/ir`: canonical intermediate representation for discovered tools
- `internal/generator`: docs, schema, and skill generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
- `skills/`: bundled agent skills source and generated skill docs
- `test/`: CLI, compatibility, integration, contract, and script tests
## Public Repository Contract
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
- `internal/app`: root command wiring, static utility commands, and plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection, identity
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
- `pkg/mcptypes`: MCP protocol type definitions
- `internal/syncdata`: generated static endpoint and command-routing data synced from the Wukong baseline
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
+26 -25
View File
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
## Project Snapshot
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
- Compatibility and helper surfaces are overlays, not the canonical truth.
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
## Repository Map
- `cmd`: public CLI entrypoint
- `internal/app`: root command wiring and command tree mount points
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
- `internal/generator`: CLI/schema/docs/skills generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
- `internal/app`: root command wiring, static utility commands, plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and static endpoint loader
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection
- `internal/audit`: user operation audit log
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `docs/`: public architecture and reference docs
- `hack/`: developer-only helper commands not shipped as public binaries
- `scripts/`: build, test, lint, packaging, and policy checks
- `test/`: integration, contract, compatibility, and script validation suites
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
## Task Routing
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
- Generated output drift: inspect `internal/generator` and run drift checks
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
- Protocol or transport issues: inspect `internal/transport`
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Generated Artifacts
## Policy Checks
Prefer editing source logic instead of generated files directly.
When command surface or plugin descriptors change, run:
- Generated-heavy paths:
- `docs/generated/`
- `skills/generated/`
- `test/golden/generated_outputs/`
- When generator or command surface changes, run:
- `./scripts/policy/check-generated-drift.sh`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-open-source-assets.sh`
## Common Commands
@@ -55,9 +59,6 @@ make build
make test
make lint
./scripts/dev/ci-local.sh
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/policy/check-open-source-assets.sh
git diff --check
```
+2 -3
View File
@@ -2,12 +2,11 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
## Global flags
+8 -9
View File
@@ -1,6 +1,6 @@
# Running the connector as a 7x24 service
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options.
@@ -15,14 +15,14 @@ terminal closes. For an unattended "digital employee" you have two options.
```bash
# Detach into a background supervisor that restarts the connector if it crashes.
dws devapp robot connect --daemon \
dws dev connect --daemon \
--channel claudecode \
--unified-app-id <unifiedAppId>
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
dws devapp robot connect status --unified-app-id <unifiedAppId>
dws devapp robot connect stop --unified-app-id <unifiedAppId>
dws devapp robot connect restart --unified-app-id <unifiedAppId>
dws dev connect status --unified-app-id <unifiedAppId>
dws dev connect stop --unified-app-id <unifiedAppId>
dws dev connect restart --unified-app-id <unifiedAppId>
```
- The parent prints the daemon pid and the log path, then exits.
@@ -60,8 +60,7 @@ and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dws</string>
<string>devapp</string>
<string>robot</string>
<string>dev</string>
<string>connect</string>
<string>--channel</string>
<string>claudecode</string>
@@ -110,7 +109,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dws devapp robot connect \
ExecStart=/usr/local/bin/dws dev connect \
--channel claudecode \
--unified-app-id REPLACE_UNIFIED_APP_ID
Restart=always
@@ -136,7 +135,7 @@ security warning to stderr. This form:
- exposes `clientSecret` to every user on the box via `ps -ef`;
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
makes rotation harder;
- means `dws devapp robot connect restart` cannot re-fetch credentials — you
- means `dws dev connect restart` cannot re-fetch credentials — you
must re-run the full command yourself.
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
+12 -16
View File
@@ -5,7 +5,7 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
@@ -22,7 +22,7 @@
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
@@ -34,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
dws schema -f pretty "dev app create" # Pretty helper-only schema view / helper-only schema 彩色分区展示
```
## Dry Run / 试运行
@@ -51,26 +51,24 @@ dws contact user search --query "Alice" -o result.json
## Schema Introspection / Schema 查询
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
静态端点模式下,产品命令和 flag 以当前二进制的 `--help` 与内置 Skill 为准。`dws schema` 仅保留 helper-only 子树(如 `dev.*`)的 schema 查询。
### 路径写法
```bash
dws schema # 列出所有产品 + 工具名
dws schema ding.send_ding_message # canonical: product.rpc_name
dws schema ding.message.send # CLI 点路径: product.group.cli_name
dws schema "ding message send" # CLI 空格路径(同上)
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
dws schema # 静态端点模式提示
dws schema "dev app create" # CLI 空格路径
dws schema --cli-path "dev app create" # 显式 flag(脚本友好,免转义)
dws schema -f pretty "dev app create" # ANSI 着色分区展示(人肉查看最舒服)
```
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
helper-only schema 以 CLI 路径为准;普通产品命令请使用 `dws <path> --help` 查看参数。
### 单工具输出字段
| 字段 | 说明 |
|------|------|
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / helper-only canonical path |
| `group` | CLI 父级 group 路径(dot-separated) |
| `title` / `description` | 工具名/说明(overlay 优先) |
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
@@ -85,10 +83,8 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl
### 筛选输出
```bash
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
dws schema "dev app create" --jq '.tool.parameters' # 只看参数 schema
dws schema "dev app create" --jq '.tool.required' # 只看必填字段
```
## Shell Completion / 自动补全
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.8
go 1.25.9
require (
github.com/RealAlexandreAI/json-repair v0.0.15
+1 -26
View File
@@ -562,24 +562,6 @@ func pushRuntimeProfile(selector string) func() {
}
}
func cleanupAuthConfigIfNoProfiles(configDir string) {
cfg, err := authpkg.LoadProfiles(configDir)
if err == nil && len(cfg.Profiles) > 0 {
return
}
if authpkg.TokenDataExistsKeychain() {
return
}
appKey, _ := authpkg.ResolveAppCredentials(configDir)
if appKey != "" {
_ = authpkg.DeleteAppTokenData(appKey)
}
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteTokenMarker(configDir)
}
func newAuthExportCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "export",
@@ -956,10 +938,6 @@ func authLoginMutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(authLoginMuted)
}
func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool {
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
}
func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive)
}
@@ -1028,10 +1006,7 @@ func clipRunes(value string, limit int) string {
}
func clearCompatCache() {
store := cacheStoreFromEnv()
if store != nil {
_ = os.RemoveAll(store.Root)
}
// Cache store removed; no-op in static endpoint mode.
}
func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
+4
View File
@@ -824,6 +824,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
if got == nil {
return len(want) == 0
-213
View File
@@ -1,213 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginAuthRegistry(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "test-product")
pluginAuthMu.Unlock()
}()
// Initially not found
if _, ok := LookupPluginAuth("test-product"); ok {
t.Error("expected LookupPluginAuth to return false for unregistered product")
}
// Register auth credentials
auth := &PluginAuth{
Token: "sk-test-token-12345",
ExtraHeaders: map[string]string{"X-Custom": "value"},
TrustedDomains: []string{"api.example.com", "*.example.com"},
}
RegisterPluginAuth("test-product", auth)
// Now should be found
got, ok := LookupPluginAuth("test-product")
if !ok {
t.Fatal("expected LookupPluginAuth to return true after registration")
}
if got != auth {
t.Error("LookupPluginAuth returned different auth instance")
}
if got.Token != "sk-test-token-12345" {
t.Errorf("Token = %q, want sk-test-token-12345", got.Token)
}
if got.ExtraHeaders["X-Custom"] != "value" {
t.Errorf("ExtraHeaders[X-Custom] = %q, want value", got.ExtraHeaders["X-Custom"])
}
if len(got.TrustedDomains) != 2 {
t.Errorf("TrustedDomains len = %d, want 2", len(got.TrustedDomains))
}
}
func TestPluginAuthRegistryIsolation(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "product-a")
delete(pluginAuthRegistry, "product-b")
pluginAuthMu.Unlock()
}()
authA := &PluginAuth{Token: "token-a"}
authB := &PluginAuth{Token: "token-b"}
RegisterPluginAuth("product-a", authA)
RegisterPluginAuth("product-b", authB)
gotA, okA := LookupPluginAuth("product-a")
gotB, okB := LookupPluginAuth("product-b")
if !okA || !okB {
t.Fatal("expected both products to be registered")
}
if gotA.Token != "token-a" {
t.Errorf("product-a Token = %q, want token-a", gotA.Token)
}
if gotB.Token != "token-b" {
t.Errorf("product-b Token = %q, want token-b", gotB.Token)
}
}
func TestDeriveToolCLIName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"web_search", "web-search"},
{"maps.search_poi", "search-poi"},
{"maps.geo", "geo"},
{"simple", "simple"},
{"a.b.deep_nested_name", "deep-nested-name"},
{"already-kebab", "already-kebab"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := deriveToolCLIName(tt.input)
if got != tt.want {
t.Errorf("deriveToolCLIName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRegisterPluginAuthFromHeaders(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "test-srv")
pluginAuthMu.Unlock()
}()
srv := market.ServerDescriptor{
Key: "test-srv",
Endpoint: "https://api.example.com/mcp/v1",
CLI: market.CLIOverlay{ID: "test-srv", Command: "test-srv"},
AuthHeaders: map[string]string{
"Authorization": "Bearer sk-my-secret-key",
"X-Custom": "custom-value",
},
}
registerPluginAuthFromHeaders(srv)
auth, ok := LookupPluginAuth("test-srv")
if !ok {
t.Fatal("expected auth to be registered after registerPluginAuthFromHeaders")
}
if auth.Token != "sk-my-secret-key" {
t.Errorf("Token = %q, want sk-my-secret-key", auth.Token)
}
if auth.ExtraHeaders["X-Custom"] != "custom-value" {
t.Errorf("ExtraHeaders[X-Custom] = %q, want custom-value", auth.ExtraHeaders["X-Custom"])
}
if len(auth.TrustedDomains) != 2 {
t.Fatalf("TrustedDomains len = %d, want 2", len(auth.TrustedDomains))
}
if auth.TrustedDomains[0] != "api.example.com" {
t.Errorf("TrustedDomains[0] = %q, want api.example.com", auth.TrustedDomains[0])
}
}
func TestRegisterPluginAuthFromHeadersNoAuth(t *testing.T) {
srv := market.ServerDescriptor{
Key: "no-auth-srv",
Endpoint: "https://api.example.com/mcp/v1",
CLI: market.CLIOverlay{ID: "no-auth-srv"},
AuthHeaders: map[string]string{
"X-Custom": "custom-value",
},
}
registerPluginAuthFromHeaders(srv)
// Should not register because there's no Authorization header
if _, ok := LookupPluginAuth("no-auth-srv"); ok {
t.Error("expected no auth registration when Authorization header is missing")
}
}
func TestBuildPluginAuthClient(t *testing.T) {
base := transport.NewClient(nil)
srv := market.ServerDescriptor{
Endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/mcp",
AuthHeaders: map[string]string{
"Authorization": "Bearer sk-test-api-key",
"X-Extra": "extra-value",
},
}
client := buildPluginAuthClient(base, srv)
// Should return a different client instance
if client == base {
t.Error("expected buildPluginAuthClient to return a new client, not the base")
}
// Verify trusted domains
if len(client.TrustedDomains) != 2 {
t.Fatalf("TrustedDomains len = %d, want 2", len(client.TrustedDomains))
}
if client.TrustedDomains[0] != "dashscope.aliyuncs.com" {
t.Errorf("TrustedDomains[0] = %q, want dashscope.aliyuncs.com", client.TrustedDomains[0])
}
}
func TestBuildPluginAuthClientNoAuth(t *testing.T) {
base := transport.NewClient(nil)
srv := market.ServerDescriptor{
Endpoint: "https://api.example.com/mcp/v1",
AuthHeaders: map[string]string{
"X-Custom": "custom-value",
},
}
client := buildPluginAuthClient(base, srv)
// Should return the base client when no Authorization header
if client != base {
t.Error("expected buildPluginAuthClient to return base client when no Authorization header")
}
}
+78
View File
@@ -0,0 +1,78 @@
// 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/spf13/cobra"
)
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
Replacement string `json:"replacement,omitempty"`
}
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
sub := &cobra.Command{
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
}
return cmd
}
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, err := json.MarshalIndent(notice, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
return err
}
}
@@ -1,157 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
// named after the reserved --params flag) aborted every invocation. With no
// on-disk cache to quarantine it must degrade to an inert stub instead.
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
// the build panics AND a discovery cache exists on disk, the partition is
// quarantined and the build retried once, so a fixed binary escapes the
// lock-out with zero manual cache surgery.
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Short != "rebuilt-probe" {
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
}
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
// if the rebuild after quarantine panics again, the stub is returned and the
// `dws cache refresh` hint kept.
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
// on the happy path.
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
cmd := newMCPCommand(context.Background(), nil, nil, nil)
if cmd == nil || cmd.Short != "canonical-probe" {
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
}
}
+8 -192
View File
@@ -1,203 +1,19 @@
// 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"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
return &cobra.Command{
Use: "catalog",
Short: "查看服务目录 (静态端点模式)",
Hidden: true,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
return cmd.Help()
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+7 -7
View File
@@ -23,8 +23,8 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
var (
@@ -57,12 +57,12 @@ func devappMCPEndpoint() string {
return defaultPATGatewayBaseURL() + devappServerPath
}
func defaultPATServerDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
func defaultPATServerDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: defaultPATProductID,
DisplayName: defaultPATDisplayName,
Endpoint: defaultPATMCPEndpoint(),
CLI: market.CLIOverlay{
CLI: mcptypes.CLIOverlay{
ID: defaultPATProductID,
Command: defaultPATProductID,
Prefixes: []string{defaultPATProductID},
@@ -104,7 +104,7 @@ func defaultPATGatewayBaseURL() string {
// SetDynamicServers injects server data discovered from servers.json.
// All product endpoints are resolved dynamically from this data.
func SetDynamicServers(servers []market.ServerDescriptor) {
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
@@ -167,7 +167,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
dynamicToolEndpoints = toolEndpoints
}
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
if server.CLI.Skip {
return
}
@@ -363,7 +363,7 @@ func DirectRuntimeProductIDs() map[string]bool {
// dynamic server registry without replacing the current entries. This
// is used by the plugin loader to inject plugin servers alongside
// Market-discovered servers.
func AppendDynamicServer(server market.ServerDescriptor) {
func AppendDynamicServer(server mcptypes.ServerDescriptor) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
@@ -1,356 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
// Regression for the chat/bot tool routing bug: when the `chat` envelope
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
// `send_message_by_custom_robot`), those tool names must NOT be registered
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
// tool.
//
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
// side), so product-level and tool-level lookups both resolve correctly.
const (
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
)
// botDescriptor returns a minimal `bot` server descriptor that owns the
// `search_my_robots` + `send_message_by_custom_robot` tools (no
// serverOverride — bot is the real owner).
func botDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testBotEndpoint,
CLI: market.CLIOverlay{
ID: "bot",
ToolOverrides: map[string]market.CLIToolOverride{
"search_my_robots": {CLIName: "search"},
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
"add_robot_to_group": {CLIName: "add-bot"},
},
},
}
}
// chatDescriptor returns a minimal `chat` server descriptor whose
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
// chat's endpoint.
func chatDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testChatEndpoint,
CLI: market.CLIOverlay{
ID: "chat",
Command: "chat",
ToolOverrides: map[string]market.CLIToolOverride{
"search_groups_by_keyword": {CLIName: "search"},
"search_my_robots": {
CLIName: "search",
ServerOverride: "bot",
},
"send_message_by_custom_robot": {
CLIName: "send-by-webhook",
ServerOverride: "bot",
},
"add_robot_to_group": {
CLIName: "add-bot",
ServerOverride: "bot",
},
},
},
}
}
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
// registries so parallel/other tests aren't affected by this case's mutations.
func withCleanDynamicRegistry(t *testing.T) {
t.Helper()
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
}
func assertEndpoint(t *testing.T, productID, toolName, want string) {
t.Helper()
got, ok := directRuntimeEndpoint(productID, toolName)
if !ok {
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
}
if got != want {
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
}
}
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
// of registration order.
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "bot first, chat second",
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
},
{
name: "chat first, bot second",
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Bot-owned tools must route to bot's endpoint even though chat
// declares toolOverrides for them (with serverOverride="bot").
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
// Chat-native tools must still route to chat.
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
// Product-level fallback for bot (no tool name) must also return
// bot's endpoint.
assertEndpoint(t, "bot", "", testBotEndpoint)
})
}
}
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
// the plugin-injection path (`AppendDynamicServer`) which has the same
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
orders := [][]market.ServerDescriptor{
{botDescriptor(), chatDescriptor()},
{chatDescriptor(), botDescriptor()},
}
for _, servers := range orders {
t.Run("", func(t *testing.T) {
withCleanDynamicRegistry(t)
for _, s := range servers {
AppendDynamicServer(s)
}
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
})
}
}
// --- Issue #219 regression tests: cross-product tool name collision ---
//
// When two different products register tools with the same name (e.g. drive
// and doc both have "create_folder"), the product-level endpoint must win
// when the caller already knows the productID. Otherwise the tool-level map
// (last-writer-wins) routes the invocation to the wrong MCP server.
const (
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
)
func driveDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDriveEndpoint,
CLI: market.CLIOverlay{
ID: "drive",
Command: "drive",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "mkdir"},
"list_files": {CLIName: "list"},
"download_file": {CLIName: "download"},
"get_upload_info": {CLIName: "upload-info"},
},
},
}
}
func docDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDocEndpoint,
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "create", Group: "folder"},
"download_file": {CLIName: "download"},
"search_documents": {CLIName: "search"},
"list_nodes": {CLIName: "list"},
},
},
}
}
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
// that when productID is known and has a registered endpoint, the product-level
// endpoint is used even if the tool-level map points to a different server
// (due to same-name tool collision). This is the core fix for issue #219.
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "drive first, doc second",
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
},
{
name: "doc first, drive second",
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Drive tools must always route to drive's endpoint regardless of
// registration order — productID "drive" is known.
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
// Doc tools must always route to doc's endpoint.
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
// Product-level fallback (no tool name) still works.
assertEndpoint(t, "drive", "", testDriveEndpoint)
assertEndpoint(t, "doc", "", testDocEndpoint)
})
}
}
// --- Command field first-writer-wins regression test ---
//
// When two plugins declare the same CLI.Command but different CLI.ID values,
// AppendDynamicServer must NOT let the second registration overwrite the
// command → endpoint mapping established by the first. The fix uses a simple
// "if not exists" guard on dynamicEndpoints[cmd].
const (
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
)
func firstPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testFirstEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-alpha",
Command: "shared-cmd",
},
}
}
func secondPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testSecondEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-beta",
Command: "shared-cmd",
},
}
}
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
// two plugins declare the same Command (but different IDs), only the first
// registration takes effect for the command → endpoint mapping. The second
// plugin's own id-based endpoint is unaffected.
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
withCleanDynamicRegistry(t)
AppendDynamicServer(firstPluginDescriptor())
AppendDynamicServer(secondPluginDescriptor())
// The command "shared-cmd" must resolve to the first plugin's endpoint.
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
// Each plugin's own id-based endpoint is always unconditionally written.
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
// Command must appear in dynamicProducts (discovery) regardless.
ids := DirectRuntimeProductIDs()
if !ids["shared-cmd"] {
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-alpha"] {
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-beta"] {
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
}
}
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
// tool-level routing still works as a fallback when productID is empty or has
// no registered endpoint (the original design intent for tool-level Priority 1).
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
// When productID is empty, tool-level endpoint is the only option.
// The actual endpoint depends on registration order (last-writer-wins),
// but the lookup must succeed.
endpoint, ok := directRuntimeEndpoint("", "create_folder")
if !ok {
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
}
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
}
// Unique tools (no collision) still resolve via tool-level.
assertEndpoint(t, "", "search_documents", testDocEndpoint)
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
}
-198
View File
@@ -1,198 +0,0 @@
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
server := defaultPATServerDescriptor()
if server.CLI.ID != "pat" {
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
}
if server.DisplayName != "行为授权" {
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
}
if server.Endpoint != defaultPATMCPEndpoint() {
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
}
}
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
dynamicMu.Lock()
previousProducts := dynamicProducts
dynamicProducts = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicProducts = previousProducts
dynamicMu.Unlock()
})
ids := DirectRuntimeProductIDs()
if !ids["pat"] {
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
}
}
func TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
withCleanDynamicRegistry(t)
ids := DirectRuntimeProductIDs()
if !ids["devapp"] {
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
}
}
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
}
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
}
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{
{
Endpoint: "https://example.test/server/devapp-supplement",
CLI: market.CLIOverlay{
ID: "devapp",
Command: "devapp",
},
},
})
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
Prefixes: []string{"devapp", "app"},
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
withCleanDynamicRegistry(t)
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-static",
Prefixes: []string{"devapp", "app"},
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
withCleanDynamicRegistry(t)
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
prev := edition.Get()
edition.Override(&edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{
ID: "devapp",
Name: "开放平台应用管理",
Endpoint: "https://example.test/server/devapp-edition-supplement",
},
}
},
})
t.Cleanup(func() { edition.Override(prev) })
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
withCleanDynamicRegistry(t)
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
withCleanDynamicRegistry(t)
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", tmpDir)
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
}
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
withCleanDynamicRegistry(t)
customEndpoint := "https://example.com/server/custom-pat"
SetDynamicServers([]market.ServerDescriptor{
{
Endpoint: customEndpoint,
CLI: market.CLIOverlay{
ID: "pat",
Command: "pat",
},
},
})
assertEndpoint(t, "pat", "", customEndpoint)
}
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
dynamicMu.Lock()
previousAliases := dynamicAliases
dynamicAliases = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicAliases = previousAliases
dynamicMu.Unlock()
})
cases := map[string]string{
"tb": "teambition",
"dingtalk-discovery": "discovery",
"dingtalk-oa-plus": "oa",
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
}
for input, want := range cases {
if got := normalizeDirectRuntimeProductID(input); got != want {
t.Fatalf("normalizeDirectRuntimeProductID(%q) = %q, want %q", input, got, want)
}
}
}
+18 -61
View File
@@ -21,8 +21,6 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -193,15 +191,12 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
baseURL := config.GetMCPBaseURL()
httpClient := &http.Client{Timeout: timeout}
client := market.NewClient(baseURL, httpClient)
start := time.Now()
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
_, err := client.FetchServers(reqCtx, 1)
latency := time.Since(start)
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
if err != nil {
r := checkResult{
Name: "network",
@@ -215,6 +210,22 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
resp, err := httpClient.Do(req)
latency := time.Since(start)
if err != nil {
r := checkResult{
Name: "network",
Status: statusFail,
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
Hint: "请检查网络连接或代理设置",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
resp.Body.Close()
r := checkResult{
Name: "network",
Status: statusPass,
@@ -233,64 +244,10 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
store := cacheStoreFromEnv()
files, _, err := cacheDirectoryStats(store.Root)
if err != nil {
r := checkResult{
Name: "cache",
Status: statusFail,
Message: fmt.Sprintf("缓存目录不可读: %v", err),
Hint: "运行 dws cache clean 清理后重试",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
entries, _ := store.ListToolsCacheEntries(config.DefaultPartition)
if files == 0 && len(entries) == 0 {
r := checkResult{
Name: "cache",
Status: statusWarn,
Message: "缓存为空 (首次使用)",
Hint: "运行任意 dws 命令后将自动建立缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
staleCount := 0
for _, e := range entries {
if e.Freshness == cache.FreshnessStale {
staleCount++
}
}
if staleCount > 0 {
r := checkResult{
Name: "cache",
Status: statusWarn,
Message: fmt.Sprintf("%d 个文件, %d 个工具缓存, %d 个已过期", files, len(entries), staleCount),
Hint: "运行 dws cache refresh 刷新缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
msg := fmt.Sprintf("%d 个文件, %d 个工具缓存", files, len(entries))
if len(entries) > 0 {
msg += ", 全部新鲜"
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: msg,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
+4 -7
View File
@@ -108,11 +108,8 @@ func TestDoctorCheckCacheEmpty(t *testing.T) {
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusWarn {
t.Errorf("expected warn for empty cache, got %s", r.Status)
}
if !strings.Contains(r.Message, "缓存为空") {
t.Errorf("expected empty cache message, got %q", r.Message)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
@@ -122,8 +119,8 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusWarn {
t.Errorf("expected warn for empty cache, got %s", r.Status)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
-354
View File
@@ -1,354 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 3, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("doc", "文档管理", nil, map[string]any{
"search_docs": map[string]any{
"cliName": "search",
"flags": map[string]any{},
},
}),
discoveryServerEntry("chat", "聊天管理", map[string]any{
"message": map[string]any{"description": "消息管理"},
}, map[string]any{
"list_messages": map[string]any{
"cliName": "list",
"group": "message",
"flags": map[string]any{},
},
}),
discoveryServerEntry("minutes", "听记管理", map[string]any{
"list": map[string]any{"description": "列表"},
}, map[string]any{
"list_minutes_mine": map[string]any{
"cliName": "mine",
"group": "list",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"chat message list-topic-replies",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
}
}
}
func TestDynamicLeafHelpDoesNotUsePatchedExamplesOrFlagText(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{
"prompt": map[string]any{
"alias": "prompt",
},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"aiapp", "create", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(aiapp create --help) error = %v", err)
}
got := out.String()
if strings.Contains(got, "创建一个天气查询应用") {
t.Fatalf("leaf help still contains patched example:\n%s", got)
}
if strings.Contains(got, "创建 AI 应用的 prompt(必填)") {
t.Fatalf("leaf help still contains patched flag usage:\n%s", got)
}
if !strings.Contains(got, "--prompt string") {
t.Fatalf("leaf help missing dynamic prompt flag:\n%s", got)
}
}
func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{},
},
}),
discoveryServerEntry("aitable", "多维表管理", nil, map[string]any{
"list_bases": map[string]any{
"cliName": "list",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(--help) error = %v", err)
}
got := out.String()
for _, want := range []string{"Discovered MCP Services:", "aiapp", "AI应用管理", "aitable", "多维表管理"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{
"prompt": map[string]any{
"alias": "prompt",
},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"aiapp", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(aiapp --help) error = %v", err)
}
got := out.String()
if !strings.Contains(got, "Usage:") || !strings.Contains(got, "Available Commands:") || !strings.Contains(got, "Flags:") {
t.Fatalf("subcommand help should still use cobra default sections:\n%s", got)
}
if strings.Contains(got, "Discovered MCP Services:") {
t.Fatalf("subcommand help should not render root-only MCP summary:\n%s", got)
}
}
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, got)
}
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
t.Fatal("upgrade command should be registered on root, but was not found")
}
}
func executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
"command": command,
"description": description,
"toolOverrides": toolOverrides,
}
if len(groups) > 0 {
cliMeta["groups"] = groups
}
return map[string]any{
"server": map[string]any{
"name": command,
"description": description,
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + command,
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active",
"isLatest": true,
},
"com.dingtalk.mcp.registry/cli": cliMeta,
},
}
}
func lookupCommand(root *cobra.Command, path string) *cobra.Command {
if root == nil || path == "" {
return root
}
cmd := root
for _, part := range strings.Fields(path) {
found := false
for _, child := range cmd.Commands() {
if child.Name() == part {
cmd = child
found = true
break
}
}
if !found {
return nil
}
}
return cmd
}
+18 -598
View File
@@ -14,170 +14,45 @@
package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
if fn := edition.Get().StaticServers; fn != nil {
injectStaticServers(fn())
// Static servers provided by the edition hook — skip Market discovery
// entirely. The overlay registers its own product commands via
// RegisterExtraCommands; we only add the open-source helpers here.
commands := helpers.NewPublicCommands(runner)
return mergeTopLevelCommands(commands)
}
return buildEnvelopeCommandsSafe(ctx, runner)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
return mergeTopLevelCommands(commands)
}
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
// panic in the cache-driven build can be simulated without crafting a
// poisoned on-disk cache.
var loadDynamicCommandsFn = loadDynamicCommands
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
// buildEnvelopeCommandsSafe builds the public command set from the discovery
// envelope, self-healing a poisoned cache when the dynamic build panics and
// degrading to the hardcoded helper commands only if that also fails.
//
// Why this guard exists: the dynamic command tree is constructed from cached
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
// a duplicate pflag registration fed by a poisoned cache, as seen before
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
// every invocation — including `dws cache refresh`, the very command that
// repairs the cache.
//
// Recovery is two-staged. First the partition's discovery cache is moved
// aside (kept on disk for inspection) and the build retried against a fresh
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
// panics again (e.g. the remote envelope itself is still poisoned, or the
// machine is offline with no usable cache) does the CLI degrade to utility
// and helper commands with a `dws cache refresh` hint.
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
return cmds
if fn := hooks.StaticServers; fn != nil {
servers = append(servers, fn()...)
}
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
return cmds
}
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
if fn := hooks.SupplementServers; fn != nil {
servers = append(servers, fn()...)
}
fmt.Fprintf(os.Stderr,
"Warning: building product commands from the local discovery cache failed: %v\n"+
"Product commands are temporarily unavailable; utility commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
}
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
// converting a panic into a return value so the caller can decide between
// self-heal and degradation.
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmds = nil
panicked = r
}
}()
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
helperCmds := helpers.NewPublicCommands(runner)
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
// Post-merge product hooks: tasks the envelope cannot express on its
// own (e.g. dual-role group+leaf semantics for deprecated aliases).
// Keep each hook narrowly scoped to one product so the open-source
// command surface remains predictable from the envelope alone.
helpers.AttachReportLegacyInboxAlias(merged, runner)
helpers.AttachReportListReadableEnrichment(merged, runner)
return merged, nil
}
// pickCommands returns the union of dynamic and helpers commands. For
// same-named top-level products, helper-only leaves are grafted into the
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
// remains the authority for leaves it declares, while hardcoded helpers can
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
// alongside the envelope's `chat message send`).
//
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
// on same-named top-level commands, which — at leaf conflicts — falls back to
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
// typically expose more flags than the corresponding dynamic overlay leaves,
// so a naive append would silently promote helper leaves over their dynamic
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
// leaf conflict, and only adding subtrees the dynamic side lacks.
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
dynByName := make(map[string]*cobra.Command, len(dynamic))
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
for _, c := range dynamic {
if c == nil {
continue
}
dynByName[c.Name()] = c
out = append(out, c)
if len(servers) == 0 {
return
}
for _, h := range helpers {
if h == nil {
continue
}
if dyn := dynByName[h.Name()]; dyn != nil {
cmdutil.MergeHardcodedLeaves(dyn, h)
continue
}
out = append(out, h)
}
return out
}
// injectStaticServers converts edition.ServerInfo entries into
// market.ServerDescriptor and feeds them into SetDynamicServers so the
// direct-runtime endpoint resolver can find them.
func injectStaticServers(servers []edition.ServerInfo) {
descriptors := make([]market.ServerDescriptor, 0, len(servers))
descriptors := make([]mcptypes.ServerDescriptor, 0, len(servers))
for _, s := range servers {
descriptors = append(descriptors, market.ServerDescriptor{
descriptors = append(descriptors, mcptypes.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
CLI: market.CLIOverlay{
CLI: mcptypes.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
@@ -187,456 +62,6 @@ func injectStaticServers(servers []edition.ServerInfo) {
SetDynamicServers(descriptors)
}
// loadDynamicCommands loads the server registry and generates CLI commands
// dynamically from CLIOverlay metadata. It consults the disk cache first.
// Within the short revalidation window it uses the cached registry directly;
// after that it revalidates against the live market registry. Once the hard
// RegistryTTL expires, a successful live registry fetch triggers a full detail
// refresh for every server so command metadata cannot stay pinned to an
// arbitrarily old snapshot. On network failure with a stale cache, it
// gracefully degrades to the cached data so the CLI remains functional
// offline.
//
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
// editionPartition returns the cache partition for the active edition.
// Thin wrapper around config.EditionPartition; kept so the many existing
// call sites in internal/app don't need to thread edition.Get() everywhere.
func editionPartition() string {
return config.EditionPartition(edition.Get().Name)
}
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
func discoveryTraceEnabled() bool {
if IsPerfDebugEnabled() {
return true
}
for _, a := range os.Args[1:] {
if a == "--debug" {
return true
}
}
return false
}
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
seen := make(map[string]struct{})
for _, s := range servers {
id := strings.TrimSpace(s.CLI.Command)
if id == "" {
id = strings.TrimSpace(s.CLI.ID)
}
if id == "" {
continue
}
seen[id] = struct{}{}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
const maxIDs = 48
if len(out) > maxIDs {
out = out[:maxIDs]
}
return out
}
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
store := cacheStoreFromEnv()
partition := editionPartition()
// Bypass the registry cache when a fixture override is active.
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
// data from their local mock server without interference from a
// stale on-disk cache written by a previous production run.
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
// so background revalidation would interfere with test expectations.
if discoveryBaseURLOverride == "" && (freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, snapshot.SavedAt)) {
go asyncRevalidateRegistry(ctx, store, partition)
}
}
if len(servers) > 0 && discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
"partition", partition,
"servers", len(servers),
"registry_freshness", string(freshness))
}
// Cache miss or bypassed: fetch from market API synchronously (first run only).
if len(servers) == 0 {
if discoveryTraceEnabled() {
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
} else {
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL())
}
}
fetchStart := time.Now()
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
if fetchErr != nil {
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch failed",
"partition", partition,
"error", fetchErr.Error())
}
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
servers = snapshot.Servers
} else {
// no-op: fall through to FallbackServers check below
}
} else {
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch ok",
"partition", partition,
"response_servers", len(resp.Servers),
"metadata_count", resp.Metadata.Count,
"normalized_servers", len(servers),
"cli_command_ids", discoveryTraceServerIDs(servers))
}
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
RecordTiming(ctx, "cache_save", time.Since(saveStart))
}
}
}
// FallbackServers: safety net when Market discovery + cache both fail.
if len(servers) == 0 {
if fn := edition.Get().FallbackServers; fn != nil {
if fb := fn(); len(fb) > 0 {
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
descriptors := editionmerge.FallbackToDescriptors(fb)
descriptors = editionmerge.MergeSupplement(descriptors)
SetDynamicServers(descriptors)
return nil
}
}
return nil
}
// Merge edition-specific supplement servers (not in Market).
servers = editionmerge.MergeSupplement(servers)
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
result := make(map[string][]market.DetailTool)
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
if server.DetailLocator.MCPID <= 0 {
continue
}
serverID := strings.TrimSpace(server.CLI.ID)
if serverID == "" {
continue
}
snap, _, err := store.LoadDetail(partition, serverID)
if err != nil {
continue
}
var payload struct {
Tools []market.DetailTool `json:"tools"`
}
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
result[serverID] = payload.Tools
}
}
return result
}
// fetchDetailsByServerID fetches MCP Detail API tool metadata for each server
// with a known mcpId. Returns a map from CLI server ID → []DetailTool.
// Results are read from / written to the disk cache (DetailTTL=7d).
// All network fetches run concurrently; best-effort (errors silently skip).
func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers []market.ServerDescriptor, store *cache.Store, forceRefresh bool) map[string][]market.DetailTool {
if ctx == nil {
ctx = context.Background()
}
partition := editionPartition()
now := time.Now().UTC()
if store != nil && store.Now != nil {
now = store.Now().UTC()
}
type entry struct {
id string
tools []market.DetailTool
}
results := make(chan entry, len(servers))
var wg sync.WaitGroup
for _, server := range servers {
mcpID := server.DetailLocator.MCPID
if mcpID <= 0 {
continue
}
serverID := strings.TrimSpace(server.CLI.ID)
if serverID == "" {
continue
}
wg.Add(1)
go func(srv market.ServerDescriptor, sID string, mID int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
slog.Error("fetchDetailsByServerID: goroutine panicked", "server", sID, "panic", r)
}
}()
// Cache hit check. Fresh entries within the short revalidation window
// are returned immediately. Older entries still serve as fallback if
// the live market detail request fails.
var cachedTools []market.DetailTool
haveCachedTools := false
if store != nil {
if snap, freshness, err := store.LoadDetail(partition, sID); err == nil {
var payload struct {
Tools []market.DetailTool `json:"tools"`
}
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
cachedTools = payload.Tools
haveCachedTools = true
}
if !forceRefresh && freshness == cache.FreshnessFresh && haveCachedTools && !cache.ShouldRevalidate(now, snap.SavedAt) {
slog.Debug("fetchDetailsByServerID: using cached detail", "id", sID)
results <- entry{id: sID, tools: cachedTools}
return
}
}
}
// Network fetch with per-server 5s timeout.
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var detail market.DetailResponse
var fetchErr error
detailURL := strings.TrimSpace(srv.DetailLocator.DetailURL)
if detailURL != "" {
detail, fetchErr = client.FetchDetailByURL(fetchCtx, detailURL)
} else {
detail, fetchErr = client.FetchDetail(fetchCtx, mID)
}
if fetchErr != nil {
slog.Debug("fetchDetailsByServerID: skipping server", "id", sID, "mcpId", mID, "error", fetchErr)
if haveCachedTools {
results <- entry{id: sID, tools: cachedTools}
}
return
}
if !detail.Success || len(detail.Result.Tools) == 0 {
if haveCachedTools {
results <- entry{id: sID, tools: cachedTools}
}
return
}
// Persist to cache.
if store != nil {
if payload, marshalErr := json.Marshal(map[string]any{"tools": detail.Result.Tools}); marshalErr == nil {
if saveErr := store.SaveDetail(partition, sID, cache.DetailSnapshot{
MCPID: mID,
Payload: payload,
}); saveErr != nil {
slog.Debug("fetchDetailsByServerID: failed to save detail cache", "id", sID, "error", saveErr)
}
}
}
slog.Debug("fetchDetailsByServerID: got tool details", "id", sID, "tools", len(detail.Result.Tools))
results <- entry{id: sID, tools: detail.Result.Tools}
}(server, serverID, mcpID)
}
// Close channel after all goroutines finish.
go func() {
wg.Wait()
close(results)
}()
result := make(map[string][]market.DetailTool)
for e := range results {
result[e.id] = e.tools
}
return result
}
// discoveryBaseURLOverride allows tests to redirect discovery to a local server.
// Must be empty in production; only set during test execution.
var discoveryBaseURLOverride string
// SetDiscoveryBaseURL sets the base URL used for dynamic server discovery.
// Intended for test use only.
func SetDiscoveryBaseURL(url string) {
discoveryBaseURLOverride = url
}
// DiscoveryBaseURL returns the effective base URL for discovery —
// discoveryBaseURLOverride if set, otherwise DefaultMarketBaseURL.
func DiscoveryBaseURL() string {
if discoveryBaseURLOverride != "" {
return discoveryBaseURLOverride
}
return config.GetMCPBaseURL()
}
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
// hosts without IPv6 networking.
func ipv4HTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 3 * time.Second}
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
},
},
}
}
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
// suitable for CLI startup network requests.
func ipv4OnlyHTTPClient() *http.Client {
return ipv4HTTPClient(5 * time.Second)
}
// fetchRegistryServers performs the server-list HTTP fetch honoring the
// active edition's DiscoveryURL override. It is the single source of truth
// for all server-list fetches (startup, async revalidation, explicit
// `cache refresh`); keeping the edition-URL branch in one place prevents
// call sites from drifting out of sync.
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
client := market.NewClient("", httpClient)
if fn := edition.Get().DiscoveryHeaders; fn != nil {
client.Headers = fn()
}
return client.FetchServersFromURL(ctx, editionURL)
}
client := market.NewClient(DiscoveryBaseURL(), httpClient)
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
}
func registryDiscoveryBaseURL() string {
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
return editionURL
}
return DiscoveryBaseURL()
}
// asyncRevalidateRegistry refreshes the registry cache in the background.
// Uses a short timeout derived from the parent context and silently ignores
// errors — the next CLI invocation will pick up the refreshed cache or retry.
func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partition string) {
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
if err != nil {
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
return
}
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
}
}
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
return nil
}
@@ -667,8 +92,3 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
})
return out
}
// mergeSupplementServers / fallbackToDescriptors have moved to
// internal/editionmerge so that both internal/cli and internal/app can
// apply the edition's SupplementServers / FallbackServers hooks against
// the same discovery pipeline (command tree + runtime catalog).
-743
View File
@@ -1,743 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// marketListResponse builds a minimal valid FetchServers JSON response.
// The server has a ToolOverride so BuildDynamicCommands emits a command.
func marketListResponse(cliID string) map[string]any {
return map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
map[string]any{
"server": map[string]any{
"name": "Test Server",
"description": "desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/test/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": cliID,
"command": cliID,
"toolOverrides": map[string]any{
"test_tool": map[string]any{
"cliName": "test",
"flags": map[string]any{},
},
},
},
},
},
},
}
}
type testCLIServerSpec struct {
id string
command string
tool string
cliName string
}
func marketListResponseForSpecs(specs ...testCLIServerSpec) map[string]any {
servers := make([]any, 0, len(specs))
for _, spec := range specs {
servers = append(servers, map[string]any{
"server": map[string]any{
"name": spec.command,
"description": spec.command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + spec.command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": spec.id,
"command": spec.command,
"toolOverrides": map[string]any{
spec.tool: map[string]any{
"cliName": spec.cliName,
"flags": map[string]any{},
},
},
},
},
})
}
return map[string]any{
"metadata": map[string]any{"count": len(servers), "nextCursor": ""},
"servers": servers,
}
}
// minimalCLIServer returns a ServerDescriptor with ToolOverrides so
// BuildDynamicCommands will emit at least one cobra command.
func minimalCLIServer(id, endpoint string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: id + "-key",
DisplayName: id,
Endpoint: endpoint,
Source: "market",
CLI: market.CLIOverlay{
ID: id,
Command: id,
ToolOverrides: map[string]market.CLIToolOverride{
"test_tool": {CLIName: "test"},
},
},
HasCLIMeta: true,
}
}
// TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork verifies that when a
// fresh registry cache exists, no network request is made.
//
// This test uses an isolated DWS_CACHE_DIR + discoveryBaseURLOverride so that:
// - useCache=true (DWS_CATALOG_FIXTURE is "")
// - The test server records any incoming request; it should NOT be hit when cache is fresh.
func TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("test-fresh"))
}))
defer srv.Close()
// Isolated cache dir with a FRESH snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC(), // fresh
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
// Point discovery to the test server. Since cache is fresh and
// useCache=true (CATALOG_FIXTURE is ""), the network should not be needed.
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh cache should be used)", got)
}
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() returned 0 commands, want >0 from fresh cache")
}
}
// TestLoadDynamicCommandsUsesStaleCacheOnStartup verifies that when the
// registry cache is stale, startup still returns commands from the cache
// instead of blocking on a synchronous market refresh.
func TestLoadDynamicCommandsUsesStaleCacheOnStartup(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
}))
defer srv.Close()
// Isolated cache dir with a STALE snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // older than RegistryTTL=24h
Servers: []market.ServerDescriptor{minimalCLIServer("stale", "https://mcp.dingtalk.com/stale/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Fatalf("loadDynamicCommands() = 0 commands, want >0 from stale cache")
}
if got := requestCount.Load(); got != 0 {
t.Errorf("startup network request count = %d, want 0 (stale cache should not block startup)", got)
}
}
// TestLoadDynamicCommandsCacheUpdatedAfterFetch verifies the cache is persisted
// after a successful network fetch (useCache=true, isolated cache dir).
func TestLoadDynamicCommandsCacheUpdatedAfterFetch(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(marketListResponse("fresh-server"))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL) // stale/empty cache → network
t.Cleanup(func() { SetDiscoveryBaseURL("") })
_ = loadDynamicCommands(context.Background(), nil)
snapshot, freshness, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() after fetch error = %v", err)
}
if freshness != cache.FreshnessFresh {
t.Errorf("cache freshness = %s, want fresh", freshness)
}
if len(snapshot.Servers) == 0 {
t.Errorf("cache servers = 0, want >0 after network fetch")
}
}
// TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError verifies that
// when the market API is unavailable but a stale cache exists, the CLI
// still generates commands from the stale data (offline degradation).
func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // stale
Servers: []market.ServerDescriptor{minimalCLIServer("degraded", "https://mcp.dingtalk.com/degraded/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() = 0 commands, want >0 (stale fallback on network error)")
}
}
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
// This test requires background refresh which only runs in production mode.
t.Skip("async revalidation disabled in test mode")
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
phase.Store(1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "create_document",
cliName: "create-document",
})
if phase.Load() == 2 {
payload = marketListResponseForSpecs(
testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
},
testCLIServerSpec{
id: "drive",
command: "drive",
tool: "list_files",
cliName: "list-files",
},
)
}
_ = json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
snapshot, _, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() error = %v", err)
}
snapshot.SavedAt = time.Now().UTC().Add(-2 * time.Hour)
if err := store.SaveRegistry("default/default", snapshot); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
phase.Store(2)
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
refreshed, _, err := store.LoadRegistry("default/default")
if err == nil && len(refreshed.Servers) == 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"archive-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/cedar":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
}
if phase.Load() == 1 {
payload["servers"] = []any{
registryServerEnvelope("doc", "doc", "2026-03-25T10:00:00Z", 1001, "archive_document", "archive-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
}
}
_ = json.NewEncoder(w).Encode(payload)
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "archive_document", "Archive Document", "archive desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
phase.Store(1)
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-2*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after aged startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after aged startup = %d, want 0", got)
}
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryTTLExpires(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
})
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "create_document", "Create Document", "create desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-25*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after registry TTL expiry = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after registry TTL expiry = %d, want 0", got)
}
}
func TestLoadDynamicCommandsUsesStaleCacheWithoutBlockingRegistryRefresh(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
}))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour),
Servers: []market.ServerDescriptor{
{
Key: "doc-key",
DisplayName: "doc",
Endpoint: "https://mcp.dingtalk.com/doc/v1",
Source: "market",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_document": {CLIName: "create-document"},
},
},
HasCLIMeta: true,
},
},
}); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
start := time.Now()
cmds := loadDynamicCommands(context.Background(), nil)
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("loadDynamicCommands() took %v, want stale cache startup under 200ms", elapsed)
}
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
}
// TestFetchDetailsByServerIDRunsConcurrently verifies that detail fetches are
// concurrent, not serial. Uses MCPID path to avoid the localhost SSRF guard.
func TestFetchDetailsByServerIDRunsConcurrently(t *testing.T) {
const numServers = 4
const delay = 50 * time.Millisecond
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"mcpId": 1, "name": "test", "description": "test",
"tools": []any{
map[string]any{"toolName": "test_tool", "toolTitle": "Test Tool", "toolDesc": "desc"},
},
},
})
}))
defer srv.Close()
servers := make([]market.ServerDescriptor, numServers)
for i := range servers {
servers[i] = market.ServerDescriptor{
DetailLocator: market.DetailLocator{MCPID: i + 1},
CLI: market.CLIOverlay{ID: "test-server-" + string(rune('a'+i))},
HasCLIMeta: true,
}
}
start := time.Now()
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, cache.NewStore(t.TempDir()), false)
elapsed := time.Since(start)
serialBound := time.Duration(numServers) * delay
if elapsed >= serialBound {
t.Errorf("elapsed %v >= serial bound %v: requests appear serial, want concurrent", elapsed, serialBound)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() = empty map, want results")
}
}
func assertDynamicCommandChildren(t *testing.T, cmds []*cobra.Command, name string, want []string) {
t.Helper()
for _, cmd := range cmds {
if cmd.Name() != name {
continue
}
got := make([]string, 0)
for _, child := range cmd.Commands() {
if child.Name() == "help" {
continue
}
got = append(got, child.Name())
}
sort.Strings(got)
sortedWant := append([]string(nil), want...)
sort.Strings(sortedWant)
if len(got) != len(sortedWant) {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
for idx := range got {
if got[idx] != sortedWant[idx] {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
}
return
}
t.Fatalf("command %q not found", name)
}
func registryServerEnvelope(id, command, updatedAt string, mcpID int, toolName, cliName string) map[string]any {
return map[string]any{
"server": map[string]any{
"name": command,
"description": command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active",
"isLatest": true,
"updatedAt": updatedAt,
"publishedAt": updatedAt,
"mcpId": mcpID,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": id,
"command": command,
"toolOverrides": map[string]any{
toolName: map[string]any{
"cliName": cliName,
"flags": map[string]any{},
},
},
},
},
}
}
func detailResponse(mcpID int, toolName, title, desc string) map[string]any {
return map[string]any{
"success": true,
"result": map[string]any{
"mcpId": mcpID,
"name": title,
"description": desc,
"tools": []any{
map[string]any{
"toolName": toolName,
"toolTitle": title,
"toolDesc": desc,
"toolRequest": `{"type":"object"}`,
"toolResponse": `{"type":"object"}`,
"actionVersion": "v1",
},
},
},
}
}
func ageCacheSnapshotsOnDisk(t *testing.T, root string, savedAt time.Time) {
t.Helper()
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".json") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
if _, ok := payload["saved_at"]; !ok {
return nil
}
payload["saved_at"] = savedAt.Format(time.RFC3339Nano)
rewritten, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, rewritten, 0o644)
})
if walkErr != nil {
t.Fatalf("ageCacheSnapshotsOnDisk() error = %v", walkErr)
}
}
// TestFetchDetailsByServerIDUsesCacheOnHit verifies that a fresh detail cache
// entry prevents any network request.
func TestFetchDetailsByServerIDUsesCacheOnHit(t *testing.T) {
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"tools": []any{}}})
}))
defer srv.Close()
store := cache.NewStore(t.TempDir())
cachedTools := []market.DetailTool{{ToolName: "cached_tool", ToolTitle: "Cached", ToolDesc: "from cache"}}
cachedJSON, _ := json.Marshal(map[string]any{"tools": cachedTools})
err := store.SaveDetail("default/default", "test-server", cache.DetailSnapshot{
SavedAt: time.Now().UTC(),
MCPID: 42,
Payload: cachedJSON,
})
if err != nil {
t.Fatalf("SaveDetail() error = %v", err)
}
servers := []market.ServerDescriptor{
{DetailLocator: market.DetailLocator{MCPID: 42}, CLI: market.CLIOverlay{ID: "test-server"}, HasCLIMeta: true},
}
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, store, false)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh detail cache should be used)", got)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() returned empty map, want cached tools")
}
}
-203
View File
@@ -1,203 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// captureStderr redirects os.Stderr for the duration of fn and returns what
// was written to it.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
pipeR, pipeW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
origStderr := os.Stderr
os.Stderr = pipeW
defer func() { os.Stderr = origStderr }()
fn()
_ = pipeW.Close()
os.Stderr = origStderr
captured, _ := io.ReadAll(pipeR)
return string(captured)
}
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
// hatch for a poisoned discovery cache: when the dynamic command build
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
// the panic. With no on-disk cache to quarantine there is nothing to
// self-heal from, so it degrades to the hardcoded helper commands and
// prints a stderr hint pointing at `dws cache refresh`.
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
// path: when the build panics AND a discovery cache exists on disk, the
// partition is quarantined (moved aside, kept for inspection) and the build
// retried once. The retry succeeding means the user gets the full dynamic
// command tree with zero manual cache surgery.
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
partition := editionPartition()
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
if strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
}
}
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
// safety net: if the rebuild after quarantine panics again (remote envelope
// still poisoned, or offline), the CLI degrades to helper commands and keeps
// the `dws cache refresh` hint.
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
var cmds []*cobra.Command
captured := captureStderr(t, func() {
cmds = newLegacyPublicCommands(context.Background(), nil)
})
if calls != 2 {
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
// transparent on the happy path: commands returned by the dynamic build
// still reach the caller unchanged.
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
orig := loadDynamicCommandsFn
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "dynamic-probe"}}
}
t.Cleanup(func() { loadDynamicCommandsFn = orig })
cmds := newLegacyPublicCommands(context.Background(), nil)
found := false
for _, c := range cmds {
if c.Name() == "dynamic-probe" {
found = true
break
}
}
if !found {
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
}
}
-219
View File
@@ -1,219 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
// the dynamic one wins — envelopes remain the runtime authority for behaviour
// they declare. The helper subtree must not slip in via
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
dyn.AddCommand(dynTask)
dynamic := []*cobra.Command{dyn}
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
hlp := &cobra.Command{Use: "todo", Short: "helper"}
hlp.AddCommand(hlpTask)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
// The dynamic leaf must still be the one we find under the top-level name.
var found *cobra.Command
for _, c := range got[0].Commands() {
if c.Name() == "task" {
found = c
}
}
if found != dynTask {
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
}
}
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
// registers siblings the discovery envelope did NOT declare (e.g.
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
// envelope's `chat message send`), those helper-only leaves are grafted into
// the dynamic subtree instead of being dropped. This is a regression guard:
// prior to this fix, pickCommands silently dropped the entire helper subtree
// whenever the top-level product name collided, which disappeared every
// helper-only leaf the envelope didn't cover.
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
dynMessage := &cobra.Command{Use: "message"}
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynMessage)
dynamic := []*cobra.Command{dyn}
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
hlpMessage := &cobra.Command{Use: "message"}
hlpMessage.AddCommand(helperOnlyLeaf)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpMessage)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
var grafted *cobra.Command
for _, child := range dynMessage.Commands() {
if child.Name() == "send-by-bot" {
grafted = child
}
}
if grafted == nil {
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
}
if grafted != helperOnlyLeaf {
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
}
}
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
// names are NOT in the dynamic set are preserved — the dynamic overlay only
// shadows products it actually covers.
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
dyn := &cobra.Command{Use: "todo"}
dynamic := []*cobra.Command{dyn}
todoHelper := &cobra.Command{Use: "todo"}
attendanceHelper := &cobra.Command{Use: "attendance"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
got := pickCommands(dynamic, helpers)
names := make(map[string]*cobra.Command, len(got))
for _, c := range got {
names[c.Name()] = c
}
if names["todo"] != dyn {
t.Fatalf("todo = %v, want dynamic", names["todo"])
}
if names["attendance"] != attendanceHelper {
t.Fatalf("attendance not preserved from helpers")
}
if names["chat"] != chatHelper {
t.Fatalf("chat not preserved from helpers")
}
if len(got) != 3 {
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
}
}
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
// when discovery returns nothing, helpers are the sole source of truth — the
// behaviour must be identical to the pre-refactor append-all code path.
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
todoHelper := &cobra.Command{Use: "todo"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, chatHelper}
got := pickCommands(nil, helpers)
if len(got) != 2 {
t.Fatalf("got %d commands, want 2", len(got))
}
if got[0] != todoHelper || got[1] != chatHelper {
t.Fatalf("pickCommands changed helpers order or identity")
}
}
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
// shape mismatch: the discovery envelope publishes `chat group members` as
// a LEAF (the get_group_members tool exposed at that CLI path), while the
// hardcoded helper has restructured `members` into a GROUP container with
// `list / add / remove / add-bot` subcommands. The helper group carries the
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
// and surface its subtree — otherwise `dws chat group members list` is
// unreachable and the user-visible regression in #164 stays.
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
dynMembers.Flags().String("id", "", "")
dynGroup := &cobra.Command{Use: "group"}
dynGroup.AddCommand(dynMembers)
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynGroup)
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
hlpList.Flags().String("id", "", "")
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
hlpMembers := &cobra.Command{Use: "members"}
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
cobracmd.SetOverridePriority(hlpMembers, 100)
hlpGroup := &cobra.Command{Use: "group"}
hlpGroup.AddCommand(hlpMembers)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpGroup)
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
if len(got) != 1 || got[0] != dyn {
t.Fatalf("got %v, want [dyn]", got)
}
// Locate the (potentially replaced) members node under chat.group.
var members *cobra.Command
for _, c := range dynGroup.Commands() {
if c.Name() == "members" {
members = c
break
}
}
if members == nil {
t.Fatalf("members node missing under dyn.chat.group after merge")
}
want := map[string]bool{"list": false, "add": false, "remove": false}
for _, sub := range members.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` after merge, missing", name)
}
}
}
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
// a misbehaving factory.
func TestPickCommands_NilsAreSkipped(t *testing.T) {
dyn := &cobra.Command{Use: "todo"}
hlp := &cobra.Command{Use: "chat"}
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
if len(got) != 2 {
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
}
if got[0] != dyn || got[1] != hlp {
t.Fatalf("unexpected ordering or identity after nil filter")
}
}
@@ -1,64 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestEditionPartition_SingleSourceOfTruth is the regression test that
// specifically targets the original bug: internal/app.loadDynamicCommands
// was computing its partition one way (editionPartition() →
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
// config.DefaultPartition ("default/default"). This meant runtime endpoint
// resolution and command-tree generation read different cache files, and
// under gray-release the two partitions carried disjoint product lists —
// the historical root cause of `dws conference meeting create` failing
// while `dws todo task list` succeeded on the same host.
//
// Keeping both sides funneled through config.EditionPartition is the
// central invariant the fix enforces. If this test ever regresses, the
// two-partition split almost certainly came back.
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
cases := []struct {
name string
edition string
want string
}{
{"open edition falls through to default/default", "", config.DefaultPartition},
{"explicit open edition remains default", "open", config.DefaultPartition},
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
edition.Override(&edition.Hooks{Name: tc.edition})
legacy := editionPartition()
shared := config.EditionPartition(edition.Get().Name)
if legacy != shared {
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
}
if legacy != tc.want {
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
}
})
}
}
@@ -1,198 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"fmt"
"sync"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
// instance is safe for goroutines saving tool snapshots concurrently, as long
// as each goroutine targets a distinct (partition, serverKey). This mirrors
// the real plugin discovery path where each goroutine owns one plugin/server.
//
// Each call serializes to its own "<key>.json.tmp" file followed by a
// rename(2) to the final path, so concurrent writers targeting distinct keys
// never collide. The invariant asserted here: after N parallel writes, the
// Store returns each written snapshot intact under LoadTools.
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
const (
partition = "default/default"
writers = 16
)
store := cache.NewStore(t.TempDir())
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
key := fmt.Sprintf("plugin:concurrent:%d", idx)
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
ServerKey: key,
}); err != nil {
t.Errorf("SaveTools(%s): %v", key, err)
}
}(i)
}
wg.Wait()
for i := 0; i < writers; i++ {
key := fmt.Sprintf("plugin:concurrent:%d", i)
snapshot, _, err := store.LoadTools(partition, key)
if err != nil {
t.Fatalf("LoadTools(%s): %v", key, err)
}
if snapshot.ServerKey != key {
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
}
}
}
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
// write path by spraying distinct server descriptors in parallel. Afterwards
// every injected product ID must be resolvable — a missing entry would
// indicate a lost write through an un-synchronized map update.
func TestAppendDynamicServerConcurrent(t *testing.T) {
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
id := fmt.Sprintf("plugin-id-%d", idx)
endpoint := fmt.Sprintf("https://example.test/%d", idx)
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
id := fmt.Sprintf("plugin-id-%d", i)
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
}
}
}
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
// survives concurrent writers — every registered client must be looked up
// afterwards. Uses nil client pointers since LookupStdioClient only compares
// keys, not values.
func TestRegisterStdioClientConcurrent(t *testing.T) {
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
key := fmt.Sprintf("plugin/%d", i)
if _, ok := LookupStdioClient(key); !ok {
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
}
}
}
// TestResolvePluginColdTimeouts covers the three code paths of the env
// parser: unset (defaults), valid duration (applied to all three slots),
// and invalid duration (logged and ignored, defaults returned).
func TestResolvePluginColdTimeouts(t *testing.T) {
t.Run("defaults when env unset", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
}
if got.httpAuth != 1500*time.Millisecond {
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
}
if got.stdio != 2*time.Second {
t.Errorf("stdio = %v, want 2s", got.stdio)
}
})
t.Run("env override applies to all slots", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
got := resolvePluginColdTimeouts()
want := 3500 * time.Millisecond
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
t.Errorf("override not propagated: %+v", got)
}
})
t.Run("invalid env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
t.Errorf("invalid env should not override defaults: %+v", got)
}
})
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "0")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
}
})
}
+13 -107
View File
@@ -19,13 +19,9 @@ import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
@@ -38,9 +34,9 @@ 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) market.CLIOverlay {
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := market.CLIOverlay{
overlay := mcptypes.CLIOverlay{
ID: serverID,
Command: serverID,
}
@@ -77,55 +73,26 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.C
return overlay
}
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
// Returns nil if tools is empty.
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
if len(tools) == 0 {
return nil
}
detailTools := make([]market.DetailTool, 0, len(tools))
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
return map[string][]market.DetailTool{overlayID: detailTools}
}
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
// server using only its manifest + overlay.json — no subprocess required.
// server using only its manifest + overlay.json.
//
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
// registration (legacy path).
//
// When a warm tools cache exists for this server, its DetailTools are passed
// to BuildDynamicCommands so flag types are enriched from the last successful
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
// only; the next startup after a successful refresh picks up the full schema.
// Dynamic command building has been removed; this now simply registers the
// server descriptor and returns nil commands.
func registerStdioServerFromOverlay(
p *plugin.Plugin,
sc plugin.StdioServerClient,
runner executor.Runner,
store *cache.Store,
) ([]*cobra.Command, market.ServerDescriptor, bool) {
) ([]*cobra.Command, mcptypes.ServerDescriptor, bool) {
overlay := resolveStdioOverlay(p, sc)
if len(overlay.ToolOverrides) == 0 {
return nil, market.ServerDescriptor{}, false
return nil, mcptypes.ServerDescriptor{}, false
}
descriptor := market.ServerDescriptor{
descriptor := mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -138,72 +105,11 @@ func registerStdioServerFromOverlay(
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
// Warm-cache enrichment: if a prior successful discovery wrote a
// non-empty tool list, use its schema to enrich flag types.
var detailsByID map[string][]market.DetailTool
if store != nil {
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides),
"commands", len(cmds),
"enriched", detailsByID != nil)
"toolOverrides", len(overlay.ToolOverrides))
return cmds, descriptor, true
}
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
// subprocess and persists the result so the next startup can enrich
// overlay-registered commands with typed flags. It never constructs cobra
// commands; command registration has already happened synchronously from
// the overlay before this function runs.
//
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
// SaveTools entirely so a transient error cannot poison the warm cache
// with a null-tools snapshot.
func refreshStdioToolsCache(
p *plugin.Plugin,
sc plugin.StdioServerClient,
store *cache.Store,
timeouts pluginColdTimeouts,
) {
if store == nil {
return
}
tools := discoverStdioTools(p, sc, timeouts)
if len(tools) == 0 {
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
"plugin", p.Manifest.Name, "server", sc.Key)
return
}
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
}); err != nil {
slog.Warn("plugin: failed to persist stdio tools cache",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return
}
slog.Debug("plugin: stdio tools cache refreshed",
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
}
// hasOverlayToolOverrides reports whether a stdio plugin server carries
// enough CLI metadata to be registered via the overlay-first path. Used by
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
// buckets without doing the overlay parse twice.
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
// Dynamic command tree building has been removed.
_ = runner
return nil, descriptor, true
}
-398
View File
@@ -1,398 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// withCleanStdioRegistry snapshots and restores the package-level stdio
// client registry so tests that call RegisterStdioClient don't leak state
// across cases.
func withCleanStdioRegistry(t *testing.T) {
t.Helper()
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
}
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
// CLIOverlay with the given tool-override map. The stdio client is created
// but never started, since the overlay-first path does not require the
// subprocess to be running for command registration.
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
t.Helper()
raw, err := json.Marshal(overlay)
if err != nil {
t.Fatalf("marshal overlay: %v", err)
}
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: pluginName,
Version: "1.0.0",
Description: pluginName + " plugin",
MCPServers: map[string]*plugin.MCPServer{
serverKey: {
Type: "stdio",
Command: "/usr/bin/true", // never executed by overlay-first path
CLI: raw,
},
},
},
Root: t.TempDir(),
}
sc := plugin.StdioServerClient{
Key: serverKey,
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
}
return p, sc
}
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
// the core promise of the overlay-first path: when overlay.json ships
// ToolOverrides, commands appear immediately — no subprocess probe.
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
Groups: map[string]market.CLIGroupDef{
"meeting": {Description: "会议控制"},
"member": {Description: "成员管理"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
store := cache.NewStore(t.TempDir())
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
}
if len(cmds) == 0 {
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
}
var root *struct{ name, path string }
_ = root
found := false
for _, c := range cmds {
if c.Name() == "conference-local" {
found = true
// Groups must be attached as sub-commands.
groups := map[string]bool{}
for _, sub := range c.Commands() {
groups[sub.Name()] = true
}
if !groups["meeting"] {
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
}
if !groups["member"] {
t.Errorf("missing 'member' group sub-command, children = %v", groups)
}
}
}
if !found {
names := []string{}
for _, c := range cmds {
names = append(names, c.Name())
}
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
}
// AppendDynamicServer registration: product ID should land in
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
// visible even under a restrictive VisibleProducts hook.
if !DirectRuntimeProductIDs()["conference-local"] {
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
}
// RegisterStdioClient side-effect: the runtime must be able to look up
// the StdioClient when the endpoint is invoked later.
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
t.Error("LookupStdioClient missing conference-local/conference-local")
}
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
}
}
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
// tools cache with a schema-bearing DetailTool and asserts the resulting
// leaf command picks up the typed flag derived from InputSchema.
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "cache-plugin",
Command: "cache-plugin",
ToolOverrides: map[string]market.CLIToolOverride{
"echo": {CLIName: "echo", Description: "Echo input"},
},
}
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "echo",
Description: "Echo the input",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"message": map[string]any{"type": "string"},
},
"required": []any{"message"},
},
},
},
}); err != nil {
t.Fatalf("SaveTools: %v", err)
}
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok || len(cmds) == 0 {
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
}
var echoLeaf *leafMatch
for _, top := range cmds {
if top.Name() != "cache-plugin" {
continue
}
for _, sub := range top.Commands() {
if sub.Name() == "echo" {
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
}
}
}
if echoLeaf == nil {
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
}
if !echoLeaf.hasFlag {
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
}
}
type leafMatch struct {
name string
hasFlag bool
}
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
// asserts the fallback contract: when overlay.json is missing toolOverrides,
// the overlay-first path declines so the caller can route the entry through
// the legacy discovery-first registerStdioServer.
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
// on runtime discovery for its tool list).
overlay := market.CLIOverlay{
ID: "legacy-plugin",
Command: "legacy-plugin",
}
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if ok {
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
}
if cmds != nil {
t.Errorf("cmds = %v, want nil", cmds)
}
if DirectRuntimeProductIDs()["legacy-plugin"] {
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
}
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
t.Error("stdio client must NOT be registered in fallback case")
}
}
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
// timeout, empty tool list), the existing warm cache must remain intact so
// the next startup still enriches flags from the last good snapshot.
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
ID: "refresh-plugin",
Command: "refresh-plugin",
})
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
goodSnapshot := cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "ping",
Description: "Health check",
InputSchema: map[string]any{"type": "object"},
},
},
}
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
t.Fatalf("seed SaveTools: %v", err)
}
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
// a no-op and must NOT overwrite the good cache with a null snapshot.
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
if err != nil {
t.Fatalf("LoadTools after failed refresh: %v", err)
}
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
}
}
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
// test for the loadPlugins split decision: stdio plugins whose overlay ships
// ToolOverrides must have their commands visible on the root immediately,
// WITHOUT waiting on any discovery handshake. It drives the same sequence
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
// visibility filter even when no discovery has run.
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Simulate a wukong-like edition that declares a static VisibleProducts
// whitelist NOT containing our plugin. This is the exact scenario where
// the original bug surfaced.
overrideVisibleProducts(t, []string{"calendar", "doc"})
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
// No discovery runs — no cache seeded. This mirrors a cold-start where
// the subprocess is unavailable (or just slow) yet the user expects
// `dws --help` to still list the plugin.
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false")
}
root := &cobra.Command{Use: "dws"}
// Also add a sibling command that is NOT a registered product so we can
// prove the visibility filter still hides non-product commands.
bogus := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(bogus)
for _, c := range cmds {
root.AddCommand(c)
}
hideNonDirectRuntimeCommands(root)
var pluginCmd *cobra.Command
for _, c := range root.Commands() {
if c.Name() == "conference-local" {
pluginCmd = c
}
}
if pluginCmd == nil {
t.Fatal("conference-local missing from root after overlay-first registration")
}
if pluginCmd.Hidden {
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
}
if !bogus.Hidden {
t.Error("bogus-not-a-product must be hidden by the visibility filter")
}
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
}
}
// TestHasOverlayToolOverrides exercises the split-decision helper used by
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
func TestHasOverlayToolOverrides(t *testing.T) {
cases := []struct {
name string
overlay market.CLIOverlay
want bool
}{
{
name: "empty overlay",
overlay: market.CLIOverlay{ID: "x", Command: "x"},
want: false,
},
{
name: "overlay with overrides",
overlay: market.CLIOverlay{
ID: "x",
Command: "x",
ToolOverrides: map[string]market.CLIToolOverride{
"foo": {CLIName: "foo"},
},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
got := hasOverlayToolOverrides(p, sc)
if got != tc.want {
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
}
})
}
}
@@ -1,158 +0,0 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
-50
View File
@@ -1,50 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"net/http"
"reflect"
"testing"
"time"
)
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
// IPv4-forcing client used by the legacy registry / discovery path. The
// custom Transport overrides DialContext to force IPv4 — without an
// explicit Proxy field it would also drop env-var proxy support.
//
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
// tests that read proxy env early would make this flaky. Asserting that the
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
// at process boot.
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
client := ipv4HTTPClient(5 * time.Second)
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
-324
View File
@@ -1,324 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_test",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-1",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery plan) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_test"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"category": "resource"`) {
t.Fatalf("output missing resource category:\n%s", out.String())
}
}
func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_exec",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-2",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery execute) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_exec"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"status": "needs_agent_action"`) {
t.Fatalf("output missing bundle status:\n%s", out.String())
}
}
func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) {
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "finalize"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery finalize) error = nil, want validation")
}
if !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want event-id requirement", err)
}
}
func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_conflict",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-conflict",
},
})
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery plan) error = nil, want conflict validation")
}
if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want mutually exclusive flags", err)
}
}
func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_legacy_finalize",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationUnknown,
RawError: "unexpected upstream failure",
Fingerprint: "fp-legacy-finalize",
},
})
executionPath := filepath.Join(configDir, "legacy_execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil {
t.Fatalf("WriteFile(legacy execution) error = %v", err)
}
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"recovery", "finalize",
"--event-id", "evt_legacy_finalize",
"--outcome", "failed",
"--execution-file", executionPath,
"-f", "json",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery finalize) error = %v", err)
}
if !strings.Contains(out.String(), `"execution_recorded": true`) {
t.Fatalf("output missing execution_recorded flag:\n%s", out.String())
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl"))
if err != nil {
t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
lastLine := lines[len(lines)-1]
if !strings.Contains(lastLine, `"phase":"finalized"`) {
t.Fatalf("expected finalized event, got %s", lastLine)
}
if !strings.Contains(lastLine, `"legacy_execution_file"`) {
t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine)
}
}
func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
setupRuntimeCommandTest(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
switch req["method"] {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
},
})
case "notifications/initialized":
w.WriteHeader(http.StatusNoContent)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"tools": []map[string]any{
{
"name": "search_documents",
"title": "Search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object"},
},
},
},
})
case "tools/call":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": "baseId is required",
},
},
"isError": true,
},
})
}
}))
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stdout) error = %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stderr) error = %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
os.Stdout = stdoutW
os.Stderr = stderrW
exitCode := Execute()
_ = stdoutW.Close()
_ = stderrW.Close()
stdoutData, _ := io.ReadAll(stdoutR)
stderrData, _ := io.ReadAll(stderrR)
if exitCode == 0 {
t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData)
}
if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") {
t.Fatalf("stderr missing recovery event id:\n%s", stderrData)
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json"))
if err != nil {
t.Fatalf("ReadFile(last_error.json) error = %v", err)
}
var last recovery.LastError
if err := json.Unmarshal(data, &last); err != nil {
t.Fatalf("json.Unmarshal(last_error) error = %v", err)
}
if last.EventID == "" || last.Context.ToolName != "search_documents" {
t.Fatalf("unexpected recovery snapshot %#v", last)
}
}
func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) {
t.Helper()
recoveryDir := filepath.Join(configDir, "recovery")
if err := os.MkdirAll(recoveryDir, 0o700); err != nil {
t.Fatalf("MkdirAll(recovery) error = %v", err)
}
data, err := json.MarshalIndent(last, "", " ")
if err != nil {
t.Fatalf("json.MarshalIndent() error = %v", err)
}
if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("WriteFile(last_error.json) error = %v", err)
}
}
+46 -689
View File
@@ -15,7 +15,6 @@ package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
@@ -23,7 +22,6 @@ import (
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"sync"
@@ -31,15 +29,10 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
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/generator"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
@@ -48,8 +41,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"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/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -303,12 +296,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
AuthTokenFunc: func(ctx context.Context) string {
return resolveRuntimeAuthToken(ctx, "")
},
LoggerFunc: FileLoggerInstance,
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
@@ -355,8 +343,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
bindPersistentFlags(root, flags)
schemaCmd := newSchemaCommand(loader)
genSkillsCmd := newGenerateSkillsCommand()
genSkillsCmd.Hidden = true
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
patCaller := newToolCallerAdapter(runner, flags)
@@ -376,17 +362,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newVersionCommand(),
newPluginCommand(),
schemaCmd,
genSkillsCmd,
mcpCmd,
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so that
// AppendDynamicServer adds plugin endpoints on top of Market
// endpoints (SetDynamicServers is called inside loadDynamicCommands).
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := loadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
@@ -497,165 +481,6 @@ func newSkillCommand() *cobra.Command {
return buildSkillCommand()
}
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
statusCmd := &cobra.Command{
Use: "status",
Short: "查看缓存状态",
RunE: func(cmd *cobra.Command, args []string) error {
jsonOut, err := cmd.Flags().GetBool("json")
if err != nil {
return apperrors.NewInternal("failed to read cache status flags")
}
store := cacheStoreFromEnv()
files, bytes, err := cacheDirectoryStats(store.Root)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read cache status: %v", err))
}
// Enumerate per-server tools cache entries.
partition := config.DefaultPartition
entries, _ := store.ListToolsCacheEntries(partition)
payload := map[string]any{
"kind": "cache_status",
"cache_root": store.Root,
"files": files,
"bytes": bytes,
}
if len(entries) > 0 {
toolEntries := make([]map[string]any, 0, len(entries))
for _, e := range entries {
toolEntries = append(toolEntries, map[string]any{
"server_key": e.ServerKey,
"freshness": string(e.Freshness),
"saved_at": e.SavedAt.Format(time.RFC3339),
"tool_count": e.ToolCount,
"ttl_remaining": e.TTLRemaining,
})
}
payload["tools"] = toolEntries
}
if jsonOut {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "缓存目录: %s\n文件数: %d 大小: %d 字节\n", store.Root, files, bytes)
if len(entries) > 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n工具缓存:")
for _, e := range entries {
age := ""
if !e.SavedAt.IsZero() {
dur := time.Since(e.SavedAt).Truncate(time.Minute)
age = fmt.Sprintf(",%s 前保存", dur)
}
ttl := ""
if e.TTLRemaining != "" {
ttl = fmt.Sprintf(",剩余 TTL %s", e.TTLRemaining)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s (%s%s,%d 个工具%s)\n",
e.ServerKey, string(e.Freshness), age, e.ToolCount, ttl)
}
}
return nil
},
}
statusCmd.Flags().Bool("json", false, "Emit cache status as JSON")
refreshCmd := &cobra.Command{
Use: "refresh",
Short: "强制刷新工具缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache refresh flags")
}
store := cacheStoreFromEnv()
transportClient := transport.NewClient(nil)
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
// Market client here is only a fallback for Detail API calls inside
// DiscoverAllRuntime; the primary server-list fetch below goes
// through fetchRegistryServers so edition DiscoveryURL wins.
service := discovery.NewService(
market.NewClient(DiscoveryBaseURL(), nil),
transportClient,
store,
)
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
if err != nil {
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
}
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
selected := selectServersForProduct(servers, product)
if strings.TrimSpace(product) != "" && len(selected) == 0 {
return apperrors.NewValidation(fmt.Sprintf("no market server matched product %q", product))
}
if len(selected) == 0 {
selected = servers
}
if err := clearRuntimeCacheForServers(store, service.CachePartition(), selected); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear cache before refresh: %v", err))
}
refreshable := filterRefreshableServers(selected)
_, failures := service.DiscoverAllRuntime(cmd.Context(), refreshable)
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存刷新完成:已刷新 %d 个服务,失败 %d 个\n缓存目录: %s\n",
len(refreshable),
len(failures),
store.Root,
)
return err
},
}
refreshCmd.Flags().String("product", "", "Refresh only the selected canonical product")
_ = refreshCmd.Flags().MarkHidden("product")
cleanCmd := &cobra.Command{
Use: "clean",
Short: "清理缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
staleOnly, err := cmd.Flags().GetBool("stale")
if err != nil {
return apperrors.NewInternal("failed to read cache clean stale flag")
}
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache clean product flag")
}
store := cacheStoreFromEnv()
removed, err := cleanCacheFiles(store.Root, product, staleOnly)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clean cache: %v", err))
}
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存清理完成:已删除 %d 个文件\n",
removed,
)
return err
},
}
cleanCmd.Flags().Bool("stale", false, "Only remove stale cache entries")
cleanCmd.Flags().String("product", "", "Clean only the selected canonical product")
cleanCmd.Hidden = true
cacheCmd.AddCommand(statusCmd, refreshCmd, cleanCmd)
return cacheCmd
}
func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
@@ -678,7 +503,7 @@ func newVersionCommand() *cobra.Command {
gc := GitCommit()
goVer := "1.24+"
arch := "MCP Dynamic Aggregation"
arch := "MCP Static Endpoint Mode"
if wantJSON {
payload := map[string]any{
@@ -716,166 +541,19 @@ func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
}
func newGenerateSkillsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate-skills",
Short: "Generate agent skills from canonical metadata",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
source, err := cmd.Flags().GetString("source")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills source flag")
}
outputRoot, err := cmd.Flags().GetString("output-root")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills output-root flag")
}
withDocs, err := cmd.Flags().GetBool("with-docs")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills with-docs flag")
}
fixture, err := cmd.Flags().GetString("fixture")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills fixture flag")
}
snapshot, err := cmd.Flags().GetString("snapshot")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills snapshot flag")
}
catalogPath := fixture
if strings.EqualFold(strings.TrimSpace(source), string(generator.CatalogSourceSnapshot)) {
catalogPath = snapshot
}
for flagName, raw := range map[string]string{
"--output-root": outputRoot,
"--fixture": fixture,
"--snapshot": snapshot,
} {
if err := validateOptionalPath(flagName, raw); err != nil {
return err
}
}
catalog, err := generator.LoadCatalogWithSource(cmd.Context(), source, catalogPath)
if err != nil {
return apperrors.NewDiscovery(fmt.Sprintf("failed to load canonical catalog: %v", err))
}
artifacts, err := generator.Generate(catalog)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to generate skill artifacts: %v", err))
}
if withDocs {
if err := generator.WriteArtifacts(outputRoot, artifacts); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated artifacts: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d artifact(s) in %s\n", len(artifacts), outputRoot)
return err
}
targets := make([]generator.Artifact, 0)
for _, artifact := range artifacts {
if !strings.HasPrefix(artifact.Path, "skills/") {
continue
}
targets = append(targets, artifact)
}
if len(targets) == 0 {
return apperrors.NewInternal("no generated skill artifacts were produced")
}
if err := generator.WriteArtifacts(outputRoot, targets); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated skills: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d skill artifact(s) in %s\n", len(targets), outputRoot)
return err
},
}
cmd.Flags().String("output-root", ".", "Directory root for generated artifacts")
cmd.Flags().Bool("with-docs", true, "Write docs/schema artifacts in addition to skills")
cmd.Flags().String("source", string(generator.CatalogSourceFixture), "Catalog source for skill generation: fixture, env, or snapshot")
cmd.Flags().String("fixture", "", "Optional path to a catalog fixture; used by --source fixture")
cmd.Flags().String("snapshot", "", "Optional path to a catalog snapshot; used by --source snapshot")
return cmd
}
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
// catalog-driven canonical build can be simulated without crafting a
// poisoned on-disk cache.
// buildMCPCommandFn is a test seam for newMCPCommand.
var buildMCPCommandFn = cli.NewMCPCommand
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
// cache when the build panics and degrading to an inert stub if that also
// fails.
//
// Why this guard exists: the canonical tree is assembled from cached catalog
// data BEFORE the legacy command build and before Cobra dispatches anything,
// so a panic here (e.g. a tool schema property named after the reserved
// --params flag, as cached during the 1.0.32 incident) used to abort every
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
// fresh fetch, then degrade with a `dws cache refresh` hint.
// newMCPCommand builds the `dws mcp` command tree.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
return cmd
}
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
panicked, quarantined)
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
return cmd
}
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
stub := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface (unavailable)",
Hidden: true,
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return buildErr
},
}
return stub
}
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
// panic into a return value so the caller can decide between self-heal and
// degradation.
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmd = nil
panicked = r
}
}()
return buildMCPCommandFn(ctx, loader, runner, engine), nil
return buildMCPCommandFn(ctx, loader, runner, engine)
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
// unless they correspond to a product discovered via dynamic server discovery
// or listed in the edition's VisibleProducts hook.
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
// unless they correspond to a static endpoint product or an edition-visible
// compatibility command.
// Public utility commands are always kept visible; explicitly hidden commands
// stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
allowedProducts := resolveVisibleProducts()
staticCommands := map[string]bool{
@@ -883,6 +561,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"api": true,
"cache": true,
"config": true,
"dev": true,
"doctor": true,
"completion": true,
"skill": true,
@@ -893,6 +572,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"recovery": true,
"schema": true,
"mcp": true,
"upgrade": true,
}
for _, cmd := range root.Commands() {
name := cmd.Name()
@@ -987,11 +667,6 @@ func deduplicateCommands(root *cobra.Command) {
}
}
func cacheStoreFromEnv() *cache.Store {
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
return cache.NewStore(cacheDir)
}
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
// servers. Timeouts only apply to the *first* discovery for a given
// plugin/server; subsequent startups take the warm cache path and bypass
@@ -1078,168 +753,6 @@ func validateOptionalPath(flagName, path string) error {
return nil
}
func cacheDirectoryStats(root string) (int, int64, error) {
if strings.TrimSpace(root) == "" {
return 0, 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, 0, nil
}
return 0, 0, err
}
files := 0
var bytes int64
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
files++
bytes += info.Size()
return nil
})
return files, bytes, err
}
func selectServersForProduct(servers []market.ServerDescriptor, product string) []market.ServerDescriptor {
product = strings.TrimSpace(strings.ToLower(product))
if product == "" {
return servers
}
selected := make([]market.ServerDescriptor, 0)
for _, server := range servers {
candidates := []string{
strings.ToLower(strings.TrimSpace(server.DisplayName)),
strings.ToLower(strings.TrimSpace(server.Key)),
strings.ToLower(strings.TrimSpace(path.Base(server.Endpoint))),
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if candidate == product || strings.Contains(candidate, product) {
selected = append(selected, server)
break
}
}
}
return selected
}
func filterRefreshableServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, server := range servers {
if server.CLI.Skip {
continue
}
filtered = append(filtered, server)
}
return filtered
}
func clearRuntimeCacheForServers(store *cache.Store, partition string, servers []market.ServerDescriptor) error {
for _, server := range servers {
for _, cacheKey := range cacheKeysForServer(server) {
if err := store.DeleteTools(partition, cacheKey); err != nil {
return err
}
}
for _, cacheKey := range detailCacheKeysForServer(server) {
if err := store.DeleteDetail(partition, cacheKey); err != nil {
return err
}
}
}
return nil
}
func cacheKeysForServer(server market.ServerDescriptor) []string {
seen := make(map[string]struct{}, 2)
keys := make([]string, 0, 2)
for _, candidate := range []string{
strings.TrimSpace(server.Key),
strings.TrimSpace(server.CLI.ID),
} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
keys = append(keys, candidate)
}
return keys
}
func detailCacheKeysForServer(server market.ServerDescriptor) []string {
key := strings.TrimSpace(server.Key)
if key != "" {
return []string{key}
}
id := strings.TrimSpace(server.CLI.ID)
if id != "" {
return []string{id}
}
return nil
}
func cleanCacheFiles(root, product string, staleOnly bool) (int, error) {
if strings.TrimSpace(root) == "" {
return 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
staleCutoff := time.Now().UTC().Add(-cache.ToolsTTL)
product = strings.TrimSpace(strings.ToLower(product))
removed := 0
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
normalizedPath := strings.ToLower(filepath.ToSlash(entryPath))
if product != "" && !strings.Contains(normalizedPath, product) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if staleOnly && info.ModTime().After(staleCutoff) {
return nil
}
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
return err
}
removed++
return nil
})
if err != nil {
return 0, err
}
return removed, nil
}
// fileLogger holds the package-level file logger for diagnostics.
// It is initialized by configureLogLevel and closed by CloseFileLogger.
var fileLogger *logging.FileLogger
@@ -1328,7 +841,7 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
// Collect all server descriptors and register auth first (fast, no I/O).
type pluginServer struct {
plugin *plugin.Plugin
srv market.ServerDescriptor
srv mcptypes.ServerDescriptor
}
var httpServers []pluginServer
@@ -1368,38 +881,21 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
}
}
// Share one cache.Store across all discovery goroutines. Each goroutine
// writes to a distinct serverKey path ("tools/<plugin>_<server>.json") with
// atomic tmp+rename, so concurrent writes to different keys never collide
// on the filesystem. Global in-process registries (AppendDynamicServer,
// RegisterStdioClient) carry their own sync.Mutex; see direct_runtime.go
// and stdio_registry.go.
sharedStore := cacheStoreFromEnv()
coldTimeouts := resolvePluginColdTimeouts()
// Phase A: stdio overlay-first registration (synchronous, no I/O).
// Plugins whose overlay.json declares ToolOverrides register their full
// command tree up-front from manifest metadata alone — no subprocess
// handshake required. This fixes the "discovery fails → no commands
// ever appear" lock-out and keeps `dws --help` reliable even when the
// underlying MCP server is temporarily unavailable.
// Plugins whose overlay.json declares ToolOverrides register their
// server descriptor up-front from manifest metadata alone.
var legacyStdioEntries []stdioEntry
for _, e := range stdioEntries {
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
_, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner)
if !ok {
legacyStdioEntries = append(legacyStdioEntries, e)
continue
}
pluginCmds = append(pluginCmds, cmds...)
}
// Phase B: fan out discovery in parallel.
// - HTTP plugins: same behaviour as before (discovery-first).
// - stdio overlay-first plugins: async cache refresh only; their
// commands are already registered. Failures are non-fatal and do
// NOT poison the warm-cache with a null-tools snapshot.
// - stdio legacy plugins (overlay without toolOverrides): preserve
// the old discovery-first path for backwards compatibility.
httpResults := make([][]*cobra.Command, len(httpServers))
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
var wg sync.WaitGroup
@@ -1407,26 +903,15 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
wg.Add(1)
go func(idx int, ps pluginServer) {
defer wg.Done()
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, coldTimeouts)
}(i, ps)
}
// overlay-first stdio: async refresh (no command building here).
for _, e := range stdioEntries {
if !hasOverlayToolOverrides(e.plugin, e.sc) {
continue
}
wg.Add(1)
go func(e stdioEntry) {
defer wg.Done()
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
}(e)
}
// legacy stdio: discovery-first (commands depend on tool list).
for i, e := range legacyStdioEntries {
wg.Add(1)
go func(idx int, e stdioEntry) {
defer wg.Done()
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, coldTimeouts)
}(i, e)
}
wg.Wait()
@@ -1468,62 +953,25 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
return pluginCmds
}
// pluginCacheKey derives the cache key used to persist a plugin MCP server's
// tool list. Prefixed with "plugin:" so entries are namespaced apart from the
// Market-derived cache, and visible distinctly via `dws cache status`.
func pluginCacheKey(pluginName, serverKey string) string {
return "plugin:" + pluginName + ":" + serverKey
}
// registerHTTPServer discovers tools from a streamable-http MCP server and
// builds CLI commands. Used for plugin-owned HTTP servers that provide CLI metadata.
//
// Startup-latency strategy (issue #119):
// - Warm cache: build commands from the persisted tools snapshot
// synchronously — no network I/O. `dws --help` returns in ms even when
// the plugin endpoint is unreachable.
// - Cold cache: synchronous discovery (Initialize + ListTools) with a tight
// timeout. The outcome — success or failure — is persisted so the next
// invocation hits the warm path. Refresh on demand via `dws cache clean`
// / `dws cache refresh`; the cache TTL (7d) otherwise expires naturally.
//
// When the server descriptor carries AuthHeaders (from plugin.json "headers"),
// a dedicated transport.Client is created with the plugin's Bearer token and
// trusted domains so that third-party MCP servers requiring independent
// authentication (e.g. Alibaba Cloud Bailian) can be discovered at startup.
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, srv.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: http server served from cache",
"plugin", p.Manifest.Name, "server", srv.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildHTTPCommandsFromTools(srv, snapshot.Tools, runner)
}
// Cold cache: synchronous discovery. Persist the outcome even on failure
// (empty tools == negative cache) so the next invocation takes the fast
// path regardless of endpoint health.
// registers the server. Dynamic command building has been removed; this now
// simply registers the server descriptor for direct runtime dispatch.
func registerHTTPServer(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
tools := discoverHTTPTools(p, srv, tc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildHTTPCommandsFromTools(srv, tools, runner)
}
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
// for an HTTP MCP server and returns the discovered tools. Returns nil on
// any transport/protocol error; errors are logged at Debug level.
func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
func discoverHTTPTools(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
// Cold-path budget. An unreachable endpoint will burn the full window
// via the TCP dial timeout; a healthy localhost/third-party endpoint
// typically responds in <200 ms. Third-party servers with auth get a
// slightly larger window to accommodate TLS + auth RTT. Operators with
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
// The outcome is persisted as a negative cache so subsequent startups
// (80 ms warm) are unaffected. See issue #119.
// TODO(remove-discovery): plugin discovery currently has no warm cache, so
// unreachable endpoints still pay this timeout during command startup.
timeout := timeouts.httpNoAuth
if len(srv.AuthHeaders) > 0 {
timeout = timeouts.httpAuth
@@ -1551,67 +999,20 @@ func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transp
return toolsResult.Tools
}
// buildHTTPCommandsFromTools converts a tool list into Cobra commands via
// the BuildDynamicCommands path. Returns nil for an empty tool list.
func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
return nil
}
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
detailsByID[strings.TrimSpace(srv.CLI.ID)] = detailTools
// If the server has no ToolOverrides (e.g. third-party MCP servers that
// only declare cli.id and cli.command), auto-generate one override per
// discovered tool so BuildDynamicCommands can create leaf commands.
if len(srv.CLI.ToolOverrides) == 0 {
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(tools))
for _, tool := range tools {
srv.CLI.ToolOverrides[tool.Name] = market.CLIToolOverride{
CLIName: deriveToolCLIName(tool.Name),
}
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
// "maps.search_poi") into a kebab-case CLI command name ("search" or
// "search-poi"). It strips common prefixes and replaces underscores/dots
// with hyphens.
func deriveToolCLIName(toolName string) string {
// Use the last segment after "." as the base name.
if idx := strings.LastIndex(toolName, "."); idx >= 0 {
toolName = toolName[idx+1:]
}
// Replace underscores with hyphens for kebab-case.
return strings.ReplaceAll(toolName, "_", "-")
// buildHTTPCommandsFromTools registers the server for direct runtime
// dispatch. Dynamic command tree building has been removed.
func buildHTTPCommandsFromTools(srv mcptypes.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
_ = srv
_ = tools
_ = runner
// Dynamic command building from compat.BuildDynamicCommands has been removed.
return nil
}
// buildPluginAuthClient creates a transport.Client copy with the plugin's
// Bearer token and trusted domains injected. This allows third-party MCP
// servers that require independent authentication to be discovered at startup.
func buildPluginAuthClient(base *transport.Client, srv market.ServerDescriptor) *transport.Client {
func buildPluginAuthClient(base *transport.Client, srv mcptypes.ServerDescriptor) *transport.Client {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1638,7 +1039,7 @@ func buildPluginAuthClient(base *transport.Client, srv market.ServerDescriptor)
// a server descriptor's AuthHeaders and registers them in the global
// PluginAuth registry. The runner uses this registry at execution time
// to inject the correct Bearer token for third-party MCP servers.
func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1668,30 +1069,10 @@ func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
})
}
// registerStdioServer initializes a stdio MCP server, discovers its tools
// via ListTools, builds CLI commands, and registers the StdioClient for
// runtime dispatch. Returns generated cobra commands.
//
// Warm-cache fast path (issue #119): when a tools snapshot is already cached
// for this plugin/server, skip the Initialize + ListTools RPC round-trip and
// rebuild commands directly from the snapshot. Cold cache falls back to
// synchronous discovery with a 4s cap and persists the outcome.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: stdio server served from cache",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildStdioCommands(p, sc, snapshot.Tools, runner)
}
// registerStdioServer initializes a stdio MCP server, discovers its tools,
// and registers the StdioClient for runtime dispatch.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
tools := discoverStdioTools(p, sc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildStdioCommands(p, sc, tools, runner)
}
@@ -1734,14 +1115,8 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
return toolsResult.Tools
}
// buildStdioCommands constructs Cobra commands from a tool list and
// registers the runtime dispatch state (StdioClient + dynamic server).
// Returns nil for an empty tool list.
//
// This is the legacy discovery-first path, used only for stdio plugins whose
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
// register commands up-front via registerStdioServerFromOverlay, bypassing
// this function entirely (see plugin_stdio_overlay.go).
// buildStdioCommands registers the stdio client and server descriptor
// for direct runtime dispatch. Dynamic command tree building has been removed.
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
slog.Debug("plugin: stdio server has no tools",
@@ -1751,21 +1126,7 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
overlay := resolveStdioOverlay(p, sc)
// Auto-generate ToolOverrides from discovered tools when not provided
// by the manifest/overlay (legacy discovery-first path).
if len(overlay.ToolOverrides) == 0 {
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
if len(overlay.Prefixes) == 0 {
overlay.Prefixes = []string{overlay.ID}
}
for _, tool := range tools {
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
IsSensitive: tool.Sensitive,
}
}
}
descriptor := market.ServerDescriptor{
descriptor := mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -1778,16 +1139,12 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(tools), "commands", len(cmds))
"tools", len(tools))
return cmds
_ = runner
return nil
}
// newPipelineEngine creates and configures the pipeline engine with
-254
View File
@@ -1,254 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
var skippedRuntimeCalls atomic.Int32
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
{
Server: market.RegistryServer{
Name: "Active Service",
Remotes: []market.RegistryRemote{
{Type: "streamable-http", URL: srv.URL + "/mcp/active"},
},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "active", Command: "active"},
},
},
{
Server: market.RegistryServer{
Name: "Skipped Service",
Remotes: []market.RegistryRemote{
{Type: "streamable-http", URL: srv.URL + "/mcp/skipped"},
},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
},
},
},
})
case "/mcp/active":
http.Error(w, "active runtime unavailable", http.StatusInternalServerError)
case "/mcp/skipped":
skippedRuntimeCalls.Add(1)
http.Error(w, "skipped runtime should not be called", http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
store := cache.NewStore(cacheDir)
const partition = "default/default"
activeKey := market.ServerKey(srv.URL + "/mcp/active")
skippedKey := market.ServerKey(srv.URL + "/mcp/skipped")
saveCachedRuntimeAndDetail(t, store, partition, activeKey)
saveCachedRuntimeAndDetail(t, store, partition, skippedKey)
saveCLIIDDetail(t, store, partition, "active")
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmd := newCacheCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"refresh"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if _, _, err := store.LoadTools(partition, activeKey); err == nil {
t.Fatal("LoadTools(active) error = nil, want cache cleared before failed refresh")
}
if _, _, err := store.LoadDetail(partition, activeKey); err == nil {
t.Fatal("LoadDetail(active) error = nil, want detail cache cleared before failed refresh")
}
if _, _, err := store.LoadDetail(partition, "active"); err != nil {
t.Fatalf("LoadDetail(active CLI.ID) error = %v, want CLI metadata preserved on failed refresh", err)
}
if _, _, err := store.LoadTools(partition, skippedKey); err == nil {
t.Fatal("LoadTools(skipped) error = nil, want skipped service cache removed")
}
if _, _, err := store.LoadDetail(partition, skippedKey); err == nil {
t.Fatal("LoadDetail(skipped) error = nil, want skipped service detail cache removed")
}
if got := skippedRuntimeCalls.Load(); got != 0 {
t.Fatalf("skipped runtime calls = %d, want 0", got)
}
}
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
// Kept deliberately generic (no edition-specific strings) — concrete values
// belong to the overlay repo that installs the hooks, not to this open core.
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
var (
editionHits atomic.Int32
marketHits atomic.Int32
gotHeaders atomic.Value // map[string]string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/edition/apis":
editionHits.Add(1)
snapshot := map[string]string{
"x-test-edition": r.Header.Get("x-test-edition"),
"x-test-client": r.Header.Get("x-test-client"),
}
gotHeaders.Store(snapshot)
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 1},
Servers: []market.ServerEnvelope{
{
Server: market.RegistryServer{
Name: "Edition Service",
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
},
},
},
})
case "/cli/discovery/apis/cedar":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
edition.Override(&edition.Hooks{
Name: "testing",
DiscoveryURL: srv.URL + "/cli/edition/apis",
DiscoveryHeaders: func() map[string]string {
return map[string]string{
"x-test-edition": "custom",
"x-test-client": "cli-refresh",
}
},
})
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmd := newCacheCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"refresh"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if got := editionHits.Load(); got != 1 {
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
}
if got := marketHits.Load(); got != 0 {
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
}
headers, _ := gotHeaders.Load().(map[string]string)
if headers == nil {
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
}
if headers["x-test-edition"] != "custom" {
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
}
if headers["x-test-client"] != "cli-refresh" {
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
}
}
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
t.Helper()
payload, err := json.Marshal(market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
},
},
})
if err != nil {
t.Fatalf("json.Marshal(cli detail payload) error = %v", err)
}
if err := store.SaveDetail(partition, cliID, cache.DetailSnapshot{
MCPID: 0,
Payload: payload,
}); err != nil {
t.Fatalf("SaveDetail(%s) error = %v", cliID, err)
}
}
func saveCachedRuntimeAndDetail(t *testing.T, store *cache.Store, partition, serverKey string) {
t.Helper()
if err := store.SaveTools(partition, serverKey, cache.ToolsSnapshot{
ServerKey: serverKey,
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "stale_tool", Title: "Stale Tool"},
},
}); err != nil {
t.Fatalf("SaveTools(%s) error = %v", serverKey, err)
}
payload, err := json.Marshal(market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
},
},
})
if err != nil {
t.Fatalf("json.Marshal(detail payload) error = %v", err)
}
if err := store.SaveDetail(partition, serverKey, cache.DetailSnapshot{
MCPID: 0,
Payload: payload,
}); err != nil {
t.Fatalf("SaveDetail(%s) error = %v", serverKey, err)
}
}
-440
View File
@@ -1,440 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"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/market"
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
// patLikeError simulates an edition-specific PAT error that implements both
// ExitCoder (exit code 4) and RawStderrError (raw JSON to stderr).
type patLikeError struct{ raw string }
func (e *patLikeError) Error() string { return e.raw }
func (e *patLikeError) ExitCode() int { return 4 }
func (e *patLikeError) RawStderr() string { return e.raw }
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
t.Parallel()
root := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation(
"bad flag",
apperrors.WithHint("Pass the required flag and retry."),
))
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("Set(format) error = %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation("bad flag"))
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
root := NewRootCommand()
root.SetArgs([]string{"mcp", "doc", "search_documents", "--json", "{"})
executed, execErr := root.ExecuteC()
if execErr == nil {
t.Fatal("ExecuteC() error = nil, want validation error")
}
if executed == nil {
t.Fatal("ExecuteC() returned nil command")
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(executed, &stdout, &stderr, execErr)
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestCompletionCommandUsesConfiguredWriter(t *testing.T) {
setupRuntimeCommandTest(t)
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"completion", "bash"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !strings.Contains(out.String(), "bash completion for dws") {
t.Fatalf("output = %q, want completion script in configured writer", out.String())
}
}
func TestUnknownSubcommandShowsHelp(t *testing.T) {
t.Parallel()
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"cache", "nonexistent-cmd"})
executed, err := root.ExecuteC()
if err == nil {
t.Fatal("ExecuteC() error = nil, want unknown command error")
}
if !isUnknownCommandError(err) {
t.Fatalf("isUnknownCommandError() = false for error: %v", err)
}
// Simulate what Execute() does: redirect output to stderr and print help
if executed == nil {
executed = root
}
executed.SetOut(&out)
_ = executed.Help()
combined := out.String()
// Help text should include the parent command's usage
if !strings.Contains(combined, "cache") {
t.Fatalf("output should contain parent command name 'cache', got:\n%s", combined)
}
// Help text should list available subcommands
if !strings.Contains(combined, "Available Commands") {
t.Fatalf("output should contain 'Available Commands', got:\n%s", combined)
}
if !strings.Contains(combined, "refresh") {
t.Fatalf("output should list 'refresh' subcommand, got:\n%s", combined)
}
}
func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"version"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-2 * time.Hour),
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
}); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"version"})
start := time.Now()
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
}
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(--help) error = %v", err)
}
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
}
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
}
}
func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
"article": map[string]any{"description": "文档文章"},
}, map[string]any{
"search_article": map[string]any{
"cliName": "search",
"group": "article",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"-h"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(-h) error = %v", err)
}
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root short help output missing MCP summary:\n%s", out.String())
}
}
func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
"article": map[string]any{"description": "文档文章"},
}, map[string]any{
"search_article": map[string]any{
"cliName": "search",
"group": "article",
"flags": map[string]any{
"keyword": map[string]any{"alias": "keyword"},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"devdoc", "article", "search", "-h"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
}
if !strings.Contains(out.String(), "搜索开放平台文档") || !strings.Contains(out.String(), "dws devdoc article search") {
t.Fatalf("nested short help output missing command help:\n%s", out.String())
}
}
func TestPrintExecutionError_RawStderrError_writes_raw_JSON_to_stderr(t *testing.T) {
t.Parallel()
rawJSON := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{}}`
err := &patLikeError{raw: rawJSON}
root := NewRootCommand()
var stdout, stderr bytes.Buffer
writeErr := printExecutionError(root, &stdout, &stderr, err)
if writeErr != nil {
t.Fatalf("printExecutionError() error = %v", writeErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty for RawStderrError", stdout.String())
}
got := strings.TrimSpace(stderr.String())
if got != rawJSON {
t.Fatalf("stderr = %q, want raw JSON %q", got, rawJSON)
}
}
func TestPrintExecutionError_RawStderrError_exit_code_is_4(t *testing.T) {
t.Parallel()
err := &patLikeError{raw: `{"code":"PAT_MEDIUM_RISK_NO_PERMISSION"}`}
exitCode := apperrors.ExitCode(err)
if exitCode != 4 {
t.Fatalf("apperrors.ExitCode(patLikeError) = %d, want 4", exitCode)
}
}
func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *testing.T) {
t.Parallel()
rawJSON := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION"}`
err := &patLikeError{raw: rawJSON}
root := NewRootCommand()
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
writeErr := printExecutionError(root, &stdout, &stderr, err)
if writeErr != nil {
t.Fatalf("printExecutionError() error = %v", writeErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty — RawStderrError should bypass JSON mode", stdout.String())
}
if !strings.Contains(stderr.String(), "PAT_HIGH_RISK_NO_PERMISSION") {
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
}
}
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
// named return + defer recover → exitCode = 5 on panic.
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
defer func() {
if r := recover(); r != nil {
exitCode = 5
}
}()
if doPanic {
panic("test panic")
}
return 0
}
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
t.Parallel()
code := simulateExecuteWithPanic(true)
if code != 5 {
t.Fatalf("panic recovery exitCode = %d, want 5", code)
}
}
func TestExecute_no_panic_returns_0(t *testing.T) {
t.Parallel()
code := simulateExecuteWithPanic(false)
if code != 0 {
t.Fatalf("no-panic exitCode = %d, want 0", code)
}
}
+366
View File
@@ -0,0 +1,366 @@
// 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"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("root help: %v\n%s", err, out.String())
}
help := out.String()
if strings.Contains(help, "● conference") {
t.Fatalf("root help should hide conference compatibility command:\n%s", help)
}
for _, want := range []string{
"● dev",
"• upgrade",
} {
if !strings.Contains(help, want) {
t.Fatalf("root help missing %q:\n%s", want, help)
}
}
}
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
t.Fatalf("chat message list-direct missing --%s", flag)
}
}
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
for _, flag := range []string{"file", "type"} {
if mediaUpload.Flags().Lookup(flag) == nil {
t.Fatalf("chat media upload missing --%s", flag)
}
}
mustFindCommand(t, root, "contact", "get")
mustFindCommand(t, root, "contact", "search")
mustFindCommand(t, root, "contact", "user", "list")
mustFindCommand(t, root, "conference", "meeting", "reserve")
}
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
label := mustFindCommand(t, root, "contact", "label")
if label.Hidden {
t.Fatal("contact label should be visible as a real command group")
}
if !containsString(label.Aliases, "role") {
t.Fatal("contact label missing role alias")
}
mustFindCommand(t, root, "contact", "label", "get")
mustFindCommand(t, root, "contact", "label", "list")
mustFindCommand(t, root, "contact", "label", "list-members")
mustFindCommand(t, root, "contact", "label", "find")
mustFindCommand(t, root, "contact", "label", "search")
mustFindCommand(t, root, "contact", "label", "info")
mustFindCommand(t, root, "contact", "label", "detail")
mustFindCommand(t, root, "contact", "label", "list-all")
getSelf := mustFindCommand(t, root, "contact", "user", "get-self")
for _, alias := range []string{"self", "me", "whoami", "current"} {
if !containsString(getSelf.Aliases, alias) {
t.Fatalf("contact user get-self missing alias %q", alias)
}
}
for _, tc := range []struct {
name string
args []string
want []string
}{
{
name: "label list",
args: []string{"--dry-run", "contact", "label", "list"},
want: []string{"get_org_labels"},
},
{
name: "label get",
args: []string{"--dry-run", "contact", "label", "get", "--names", "admin,finance"},
want: []string{"search_label_by_name", "labelNames", "admin", "finance"},
},
{
name: "label members",
args: []string{"--dry-run", "contact", "label", "list-members", "--id", "123"},
want: []string{"get_label_members_by_labelId", "labelId", "123"},
},
{
name: "role shim",
args: []string{"--dry-run", "contact", "role", "list"},
want: []string{"get_org_labels"},
},
{
name: "label fuzzy shim",
args: []string{"--dry-run", "contact", "label", "find", "--names", "admin"},
want: []string{"search_label_by_name", "labelNames", "admin"},
},
{
name: "label detail shim",
args: []string{"--dry-run", "contact", "label", "detail", "--id", "123"},
want: []string{"get_label_members_by_labelId", "labelId", "123"},
},
{
name: "contact search shim",
args: []string{"--dry-run", "contact", "search", "--query", "admin"},
want: []string{"search_contact_by_key_word", "keyword", "admin"},
},
{
name: "contact find shim",
args: []string{"--dry-run", "contact", "find", "--query", "admin"},
want: []string{"search_contact_by_key_word", "keyword", "admin"},
},
{
name: "contact list defaults to label list",
args: []string{"--dry-run", "contact", "list"},
want: []string{"get_org_labels"},
},
{
name: "contact list department members",
args: []string{"--dry-run", "contact", "list", "--depts", "1"},
want: []string{"get_dept_members_by_deptId", "deptIds", "1"},
},
{
name: "contact get user details",
args: []string{"--dry-run", "contact", "get", "--ids", "user1"},
want: []string{"get_user_info_by_user_ids", "user_id_list", "user1"},
},
{
name: "contact get label by name",
args: []string{"--dry-run", "contact", "get", "--names", "admin"},
want: []string{"search_label_by_name", "labelNames", "admin"},
},
{
name: "contact self shim",
args: []string{"--dry-run", "contact", "self"},
want: []string{"get_current_user_profile"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := executeRootCaptureStdout(t, tc.args)
if err != nil {
t.Fatalf("Execute(%v) error = %v\n%s", tc.args, err, got)
}
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Fatalf("Execute(%v) output missing %q:\n%s", tc.args, want, got)
}
}
})
}
}
func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
root := NewRootCommand()
fileCmd := mustFindCommand(t, root, "chat", "file")
if !fileCmd.Hidden {
t.Fatal("chat file should be hidden after upload_conversation_file_by_url downline")
}
upload := mustFindCommand(t, root, "chat", "file", "upload")
if !upload.Hidden {
t.Fatal("chat file upload should be hidden after downline")
}
for _, flag := range []string{"group", "url", "file", "file-name"} {
if upload.Flags().Lookup(flag) == nil {
t.Fatalf("chat file upload missing compatibility flag --%s", flag)
}
}
send := mustFindCommand(t, root, "chat", "message", "send")
for _, flag := range []string{"msg-type", "file-path"} {
if send.Flags().Lookup(flag) == nil {
t.Fatalf("chat message send missing --%s", flag)
}
}
got, err := executeRootCaptureStdout(t, []string{
"chat", "file", "upload",
"--group", "cid",
"--url", "https://example.com/report.pdf",
"--file-name", "report.pdf",
})
if err == nil {
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
}
got = got + "\n" + err.Error()
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
}
}
}
func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "list",
"--start", "2026-07-07T00:00:00+08:00",
"--end", "2026-07-07T01:00:00+08:00",
})
if err != nil {
t.Fatalf("calendar event list --dry-run error = %v\n%s", err, got)
}
for _, want := range []string{"list_calendar_events", "startTime", "endTime"} {
if !strings.Contains(got, want) {
t.Fatalf("calendar dry-run output missing %q:\n%s", want, got)
}
}
}
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
root := NewRootCommand()
listBySender := mustFindCommand(t, root, "chat", "message", "list-by-sender")
if listBySender.Flags().Lookup("sender") == nil {
t.Fatal("chat message list-by-sender missing hidden --sender alias")
}
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
for _, flag := range []string{"sender", "senders", "sender-ids"} {
if searchAdvanced.Flags().Lookup(flag) == nil {
t.Fatalf("chat message search-advanced missing --%s", flag)
}
}
}
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
}
func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
previous := edition.Get()
defer edition.Override(previous)
defer SetDynamicServers(nil)
edition.Override(&edition.Hooks{
Name: "test",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{{
ID: "static-test",
Name: "Static Test",
Endpoint: "https://static.example/server/static-test",
Prefixes: []string{"static-alias"},
}}
},
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{{
ID: "supplement-test",
Name: "Supplement Test",
Endpoint: "https://supplement.example/server/supplement-test",
Prefixes: []string{"supplement-alias"},
}}
},
})
injectStaticServers()
for _, tc := range []struct {
productID string
endpoint string
}{
{"static-test", "https://static.example/server/static-test"},
{"static-alias", "https://static.example/server/static-test"},
{"supplement-test", "https://supplement.example/server/supplement-test"},
{"supplement-alias", "https://supplement.example/server/supplement-test"},
} {
got, ok := directRuntimeEndpoint(tc.productID, "")
if !ok || got != tc.endpoint {
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", tc.productID, got, ok, tc.endpoint)
}
}
}
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
t.Helper()
cmd := root
for _, name := range path {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
t.Fatalf("missing command path %q under %q", strings.Join(path, " "), cmd.CommandPath())
}
cmd = next
}
return cmd
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func executeRootCaptureStdout(t *testing.T, args []string) (string, error) {
t.Helper()
oldStdout := os.Stdout
readPipe, writePipe, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe error = %v", err)
}
os.Stdout = writePipe
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(args)
execErr := cmd.Execute()
_ = writePipe.Close()
os.Stdout = oldStdout
captured, readErr := io.ReadAll(readPipe)
if readErr != nil {
t.Fatalf("read stdout pipe error = %v", readErr)
}
return out.String() + string(captured), execErr
}
+8 -4
View File
@@ -412,12 +412,16 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
invocation.DryRun = true
return r.fallback.Run(ctx, invocation)
}
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
actions := []string{"dws cache refresh"}
hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。"
actions := []string{
"确认 internal/syncdata.StaticServers() 是否包含该 product/server",
"运行 sync-oss 重新生成静态端点与路由",
"若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令",
}
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
actions = []string{
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
"检查 StaticServers/SupplementServers 是否包含 devapp endpoint",
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
}
}
@@ -1,192 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
// catalog has the product entry (materialised from SupplementServers) but
// no tool list — the overlay owns the tool tree locally.
type supplementOnlyCatalogLoader struct{}
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
return ir.Catalog{
Products: []ir.CanonicalProduct{
{
ID: "conference",
ServerKey: "conference",
Endpoint: "stdio://conference-catalog",
Tools: nil,
},
},
}, nil
}
func resetDynamicServers(t *testing.T) {
t.Helper()
orig := snapshotDynamicServers()
t.Cleanup(func() { restoreDynamicServers(orig) })
}
type dynamicServerSnapshot struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}
func snapshotDynamicServers() dynamicServerSnapshot {
dynamicMu.RLock()
defer dynamicMu.RUnlock()
return dynamicServerSnapshot{
endpoints: cloneStringMap(dynamicEndpoints),
products: cloneBoolMap(dynamicProducts),
aliases: cloneStringMap(dynamicAliases),
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
}
}
func restoreDynamicServers(s dynamicServerSnapshot) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
dynamicEndpoints = s.endpoints
dynamicProducts = s.products
dynamicAliases = s.aliases
dynamicToolEndpoints = s.toolEndpoints
}
func cloneStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneBoolMap(in map[string]bool) map[string]bool {
if in == nil {
return nil
}
out := make(map[string]bool, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
// bridge between the catalog path (where a product entry can come from
// SupplementServers with no tool list) and the direct-runtime path (which
// carries the authoritative per-tool endpoint map). When the catalog knows
// the product but not the tool, the runner should not fail-fast with
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
// and proceed if an endpoint is registered.
//
// This is the narrow recovery path that keeps hardcoded overlay commands
// working under a gray-released envelope: the supplement-materialised
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
// operational endpoint indexed by product / command.
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
resetDynamicServers(t)
SetDynamicServers([]market.ServerDescriptor{
{
Key: "conference",
DisplayName: "会议",
Endpoint: "stdio://conference-fake",
CLI: market.CLIOverlay{
ID: "conference",
Command: "conference",
},
Source: "edition_supplement",
},
})
runner := &runtimeRunner{
loader: supplementOnlyCatalogLoader{},
transport: transport.NewClient(nil),
fallback: executor.EchoRunner{},
}
// Kind = api_invocation forces the code to skip the Run() opening
// direct-runtime attempt and go through the catalog path instead, so
// the tool-miss recovery branch we're testing actually runs.
inv := executor.Invocation{
Kind: "api_invocation",
CanonicalProduct: "conference",
Tool: "create_meeting_reservation",
CanonicalPath: "conference.create_meeting_reservation",
DryRun: true,
Params: map[string]any{},
}
result, err := runner.Run(context.Background(), inv)
if err != nil {
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
}
if result.Response == nil {
t.Fatalf("expected non-nil Response on dry-run")
}
if got, _ := result.Response["dry_run"].(bool); !got {
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
}
if got, _ := result.Response["transport"].(string); got != "stdio" {
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
}
}
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
// guard: when both the catalog tool list and dynamicEndpoints have no
// record for the requested tool, the runner must still surface
// endpoint_not_resolved instead of silently producing empty output.
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
resetDynamicServers(t)
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
runner := &runtimeRunner{
loader: supplementOnlyCatalogLoader{},
transport: transport.NewClient(nil),
fallback: executor.EchoRunner{},
}
inv := executor.Invocation{
Kind: "api_invocation",
CanonicalProduct: "conference",
Tool: "nonexistent_tool",
CanonicalPath: "conference.nonexistent_tool",
Params: map[string]any{},
}
_, err := runner.Run(context.Background(), inv)
if err == nil {
t.Fatalf("expected endpoint_not_resolved error, got nil")
}
if !strings.Contains(err.Error(), "endpoint not resolved") {
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
}
if !strings.Contains(err.Error(), "nonexistent_tool") {
t.Fatalf("error should name the missing tool; got %v", err)
}
}
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -210,7 +210,9 @@ func newSkillSearchCommand() *cobra.Command {
}
cmd.Flags().String("query", "", "搜索关键词(必填)")
_ = cmd.MarkFlagRequired("query")
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
cmd.Flags().String("source", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)")
cmd.Flags().String("scopes", "", "查询范围(已废弃,请使用 --source)")
_ = cmd.Flags().MarkDeprecated("scopes", "请使用 --source 替代")
return cmd
}
@@ -289,15 +291,18 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
func runSkillFind(cmd *cobra.Command, args []string) error {
keyword, _ := cmd.Flags().GetString("query")
scopes, _ := cmd.Flags().GetString("scopes")
source, _ := cmd.Flags().GetString("source")
if source == "" {
source, _ = cmd.Flags().GetString("scopes")
}
accessToken, err := loadSkillAccessToken()
if err != nil {
return err
}
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
if scopes != "" {
apiURL += "&scopes=" + url.QueryEscape(scopes)
if source != "" {
apiURL += "&source=" + url.QueryEscape(source)
}
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
if err != nil {
+82
View File
@@ -653,6 +653,88 @@ func TestSkillSearchCommandValidation(t *testing.T) {
}
}
func TestSkillSearchHelpUsesWukongSourceAndKeepsScopesHidden(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "search", "--help"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
help := out.String()
if !strings.Contains(help, "--source") {
t.Fatalf("help missing --source:\n%s", help)
}
if strings.Contains(help, "--scopes") {
t.Fatalf("deprecated --scopes should stay hidden from help:\n%s", help)
}
search := mustFindCommand(t, NewRootCommand(), "skill", "search")
if search.Flags().Lookup("scopes") == nil {
t.Fatal("skill search missing hidden compatibility --scopes")
}
}
func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
}
var gotSources []string
var gotScopes []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/find-skills" {
t.Fatalf("path = %q, want /cli/find-skills", r.URL.Path)
}
if got := r.Header.Get("x-user-access-token"); got != "test-token" {
t.Fatalf("x-user-access-token = %q, want test-token", got)
}
q := r.URL.Query()
gotSources = append(gotSources, q.Get("source"))
gotScopes = append(gotScopes, q.Get("scopes"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"result":[]}`))
}))
defer server.Close()
t.Setenv("DWS_SKILL_API_HOST", server.URL)
run := func(args ...string) {
t.Helper()
cmd := NewRootCommand()
cmd.SetArgs(args)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\n%s", args, err, out.String())
}
}
run("skill", "search", "--query", "周报", "--source", "OrgInternal", "--format", "json")
run("skill", "search", "--query", "周报", "--scopes", "DingtalkMarket", "--format", "json")
if len(gotSources) != 2 {
t.Fatalf("request count = %d, want 2", len(gotSources))
}
if gotSources[0] != "OrgInternal" || gotSources[1] != "DingtalkMarket" {
t.Fatalf("source query values = %#v, want OrgInternal/DingtalkMarket", gotSources)
}
if gotScopes[0] != "" || gotScopes[1] != "" {
t.Fatalf("deprecated scopes must be normalized to source query, got scopes=%#v", gotScopes)
}
}
func TestSkillFindHintCommand(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "find"})
+13
View File
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
fmt.Fprintln(out, "将安装到:")
for _, d := range dests {
fmt.Fprintf(out, " - %s\n", d)
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
}
return nil
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
+13
View File
@@ -66,3 +66,16 @@ func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
}
}
func TestRepositoryDoesNotTrackInstalledQoderSkills(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
if _, err := os.Stat(filepath.Join(repoRoot, ".qoder", "skills")); err == nil {
t.Fatal(".qoder/skills is an Agent install target, not a repository skill source; keep source skills under skills/")
} else if !os.IsNotExist(err) {
t.Fatalf("stat .qoder/skills: %v", err)
}
}
+14
View File
@@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func (a *toolCallerAdapter) Fields() string {
if a.flags != nil {
return a.flags.Fields
}
return ""
}
func (a *toolCallerAdapter) JQ() string {
if a.flags != nil {
return a.flags.JQ
}
return ""
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
+63 -31
View File
@@ -42,6 +42,7 @@ func newUpgradeCommand() *cobra.Command {
flagForce bool
flagSkipSkills bool
flagAll bool
flagBeta bool
)
cmd := &cobra.Command{
@@ -54,8 +55,10 @@ func newUpgradeCommand() *cobra.Command {
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出最近版本
dws upgrade --list --all # 列出所有版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --list --all # 列出所选轨道的全部版本
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定正式版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
@@ -72,19 +75,23 @@ func newUpgradeCommand() *cobra.Command {
yes, _ := cmd.Flags().GetBool("yes")
dryRun, _ := cmd.Flags().GetBool("dry-run")
format := resolveUpgradeFormat(cmd)
track := upgradeTrack(flagBeta)
if flagBeta && flagVersion != "" {
return fmt.Errorf("--beta 与 --version 不能同时使用;安装指定 beta 版本请直接使用 --version vX.Y.Z-beta.N")
}
if flagList {
limit := defaultListLimit
if flagAll {
limit = 0
}
return runUpgradeList(cmd, format, limit)
return runUpgradeList(cmd, format, limit, track)
}
if flagRollback {
return runUpgradeRollback(yes)
}
if flagCheck {
return runUpgradeCheck(cmd, format)
return runUpgradeCheck(cmd, format, track)
}
return runUpgrade(cmd.Context(), upgradeOptions{
targetVersion: flagVersion,
@@ -92,14 +99,16 @@ func newUpgradeCommand() *cobra.Command {
skipSkills: flagSkipSkills,
yes: yes,
dryRun: dryRun,
track: track,
})
},
}
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本")
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本")
cmd.Flags().BoolVar(&flagList, "list", false, "列出正式 release 版本(配合 --beta 查看 beta)")
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所选轨道的全部版本")
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
@@ -113,18 +122,19 @@ type upgradeOptions struct {
skipSkills bool
yes bool
dryRun bool
track upgrade.ReleaseTrack
}
// --- dws upgrade --check ---
func runUpgradeCheck(cmd *cobra.Command, format string) error {
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("检查更新..."))
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
}
latest, err := client.FetchLatestRelease()
latest, err := client.FetchLatestReleaseForTrack(track)
if err != nil {
return fmt.Errorf("检查更新失败: %w", err)
}
@@ -137,6 +147,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
"current_version": ensureV(currentVer),
"latest_version": "v" + latest.Version,
"needs_upgrade": needsUpgrade,
"track": string(track),
"release_date": latest.Date,
"prerelease": latest.Prerelease,
"changelog": parseChangelogEntries(latest.Changelog, 10),
@@ -155,7 +166,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
}
if latest.Prerelease {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
fmt.Printf(" %s %s\n", ugBold("轨道: "), ugYellow("beta / pre-release"))
}
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
fmt.Printf(" %s\n", ugBold("更新内容:"))
@@ -164,7 +175,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
}
}
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
fmt.Printf(" %s\n", ugDim(upgradeHintForTrack(track)))
return nil
}
@@ -172,14 +183,14 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
// runUpgradeList displays available versions. When limit > 0, only the most
// recent `limit` versions are shown; pass 0 to show all (--all flag).
func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
}
versions, err := client.FetchAllReleases()
versions, err := client.FetchReleaseVersions(track)
if err != nil {
return fmt.Errorf("获取版本列表失败: %w", err)
}
@@ -194,7 +205,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
currentVer := strings.TrimPrefix(version, "v")
if format == "json" {
var items []map[string]any
items := make([]map[string]any, 0, len(versions))
for _, v := range versions {
items = append(items, map[string]any{
"version": "v" + v.Version,
@@ -207,6 +218,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
result := map[string]any{
"current_version": ensureV(version),
"versions": items,
"track": string(track),
"total": totalCount,
}
if truncated {
@@ -217,7 +229,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
}
if totalCount == 0 {
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
fmt.Printf(" %s\n", ugYellow(fmt.Sprintf("未找到任何%s", upgradeTrackVersionName(track))))
return nil
}
@@ -228,7 +240,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
for _, v := range versions {
releaseType := ugGreen("stable")
if v.Prerelease {
releaseType = ugYellow("pre-release")
releaseType = ugYellow("beta")
}
versionStr := fmt.Sprintf("v%-11s", v.Version)
marker := ""
@@ -245,7 +257,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
if truncated {
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount)))
}
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本"))
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --beta 安装最新 beta;使用 --version v1.0.7 安装指定版本"))
return nil
}
@@ -327,7 +339,7 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills
}
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim("检查更新..."))
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
return fmt.Errorf("初始化目录结构失败: %w", err)
@@ -340,13 +352,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
var err error
if opts.targetVersion != "" {
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
fmt.Printf(" 指定版本: %s\n", ugCyan(ensureV(opts.targetVersion)))
release, err = client.FetchReleaseByTag(opts.targetVersion)
if err != nil {
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
}
} else {
release, err = client.FetchLatestRelease()
release, err = client.FetchLatestReleaseForTrack(opts.track)
if err != nil {
return fmt.Errorf("检查更新失败: %w", err)
}
@@ -364,7 +376,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
}
if release.Prerelease {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
fmt.Printf(" %s %s\n", ugBold("轨道: "), ugYellow("beta / pre-release"))
}
// --dry-run: preview only. Resolve the platform asset so a missing build is
@@ -560,15 +572,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Clear discovery-derived caches so the upgraded binary rebuilds its
// command tree from a fresh fetch instead of inheriting snapshots written
// by the old version — a poisoned snapshot used to lock out every
// invocation before the build guards landed (#447 / #449).
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
} else if len(purged) > 0 {
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
}
// Discovery cache purge removed — static endpoint mode has no discovery cache.
// Cleanup old backups
rm.Cleanup(5)
@@ -816,6 +820,34 @@ func ensureV(ver string) string {
return ver
}
func upgradeTrack(beta bool) upgrade.ReleaseTrack {
if beta {
return upgrade.ReleaseTrackBeta
}
return upgrade.ReleaseTrackRelease
}
func upgradeTrackSuffix(track upgrade.ReleaseTrack) string {
if track == upgrade.ReleaseTrackBeta {
return " (beta)"
}
return ""
}
func upgradeTrackVersionName(track upgrade.ReleaseTrack) string {
if track == upgrade.ReleaseTrackBeta {
return "beta 版本"
}
return "正式 release 版本"
}
func upgradeHintForTrack(track upgrade.ReleaseTrack) string {
if track == upgrade.ReleaseTrackBeta {
return "运行 dws upgrade --beta 进行升级"
}
return "运行 dws upgrade 进行升级"
}
// resolveUpgradeFormat returns "json" only when the user explicitly passes -f json.
// Unlike other commands, upgrade defaults to table (human-friendly) output.
func resolveUpgradeFormat(cmd *cobra.Command) string {
+28 -1
View File
@@ -395,7 +395,7 @@ func TestNewUpgradeCommand_Flags(t *testing.T) {
t.Errorf("Use = %q, want upgrade", cmd.Use)
}
expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
expectedFlags := []string{"check", "list", "version", "beta", "rollback", "force", "skip-skills"}
for _, name := range expectedFlags {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("missing flag: --%s", name)
@@ -430,6 +430,9 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
if !strings.Contains(help, "--rollback") {
t.Error("help should contain --rollback")
}
if !strings.Contains(help, "--beta") {
t.Error("help should contain --beta")
}
// Regression for #364: --dry-run must be discoverable from upgrade help so
// users know it is supported (and is now actually honored).
if !strings.Contains(help, "--dry-run") {
@@ -437,6 +440,30 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
}
}
func TestNewUpgradeCommand_BetaAndVersionAreMutuallyExclusive(t *testing.T) {
cmd := newUpgradeCommand()
cmd.SetArgs([]string{"--beta", "--version", "v1.0.8-beta.1"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --beta with --version")
}
if !strings.Contains(err.Error(), "--beta") || !strings.Contains(err.Error(), "--version") {
t.Fatalf("error = %q, want to mention --beta and --version", err.Error())
}
}
func TestUpgradeTrack(t *testing.T) {
if got := upgradeTrack(false); got != "release" {
t.Fatalf("upgradeTrack(false) = %q, want release", got)
}
if got := upgradeTrack(true); got != "beta" {
t.Fatalf("upgradeTrack(true) = %q, want beta", got)
}
if got := upgradeHintForTrack("beta"); !strings.Contains(got, "--beta") {
t.Fatalf("upgradeHintForTrack(beta) = %q, want --beta hint", got)
}
}
// --- writeDryRunPlan (#364) ---
//
// Regression for #364: `dws upgrade --dry-run` previously performed a real
-151
View File
@@ -1,151 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// overrideVisibleProducts temporarily installs an edition hook exposing the
// given static product list and restores the previous hooks on cleanup.
func overrideVisibleProducts(t *testing.T, products []string) {
t.Helper()
prev := edition.Get()
edition.Override(&edition.Hooks{
VisibleProducts: func() []string { return products },
})
t.Cleanup(func() { edition.Override(prev) })
}
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
// the product ID ends up in DirectRuntimeProductIDs() without triggering
// network discovery.
func registerPluginProduct(t *testing.T, id, endpoint string) {
t.Helper()
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
// is a regression for the dws-wukong plugin-visibility bug: when an edition
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
// IDs) and a plugin registers a new product via AppendDynamicServer
// (e.g. `conference-local`), the plugin command must stay visible because the
// dynamic registry takes precedence over the hook's static whitelist.
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
hideNonDirectRuntimeCommands(root)
if calendarCmd.Hidden {
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
}
if pluginCmd.Hidden {
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
}
if !bogusCmd.Hidden {
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
}
}
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
// renderer surfaces plugin products in the "Discovered MCP Services" section
// and does not misclassify them as utility commands.
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
withCleanDynamicRegistry(t)
overrideVisibleProducts(t, []string{"calendar"})
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
root := &cobra.Command{Use: "dws"}
calendarCmd := &cobra.Command{Use: "calendar"}
pluginCmd := &cobra.Command{Use: "conference-local"}
authCmd := &cobra.Command{Use: "auth"}
root.AddCommand(calendarCmd, pluginCmd, authCmd)
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
}
if !containsCommand(services, "calendar") {
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
}
utilities := visibleUtilityRootCommands(root)
if containsCommand(utilities, "conference-local") {
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
}
if !containsCommand(utilities, "auth") {
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
}
}
func containsCommand(cmds []*cobra.Command, name string) bool {
for _, c := range cmds {
if c.Name() == name {
return true
}
}
return false
}
func commandNames(cmds []*cobra.Command) []string {
names := make([]string, 0, len(cmds))
for _, c := range cmds {
names = append(names, c.Name())
}
return names
}
// TestRenderRootHelpIncludesLong guards that renderRootHelp surfaces the
// root command's Long description in `dws --help` output. The custom
// SetHelpFunc in root_help.go replaces cobra's default help template, which
// previously caused root.Long to be silently dropped. The production
// root.Long carries the "use 'dws upgrade' if a command is missing or
// failing" hint that AI agents rely on when they cannot find a suitable
// command — if this test fails after a help-rendering change, agents will
// silently lose that guidance.
func TestRenderRootHelpIncludesLong(t *testing.T) {
const sentinel = "SENTINEL-LONG-MUST-APPEAR-IN-HELP"
root := &cobra.Command{
Use: "dws",
Long: sentinel,
}
var out bytes.Buffer
root.SetOut(&out)
renderRootHelp(root)
if !strings.Contains(out.String(), sentinel) {
t.Fatalf("renderRootHelp must render root.Long verbatim in --help output; got:\n%s", out.String())
}
}
-26
View File
@@ -1,26 +0,0 @@
package cache
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
// ChangedServerKeysByUpdatedAt returns the set of live server keys that should
// be refreshed because they are new or their existing registry updatedAt value
// changed. This intentionally uses only existing market registry metadata.
func ChangedServerKeysByUpdatedAt(cached, live []market.ServerDescriptor) map[string]bool {
cachedByKey := make(map[string]market.ServerDescriptor, len(cached))
for _, server := range cached {
cachedByKey[server.Key] = server
}
changed := make(map[string]bool)
for _, server := range live {
previous, ok := cachedByKey[server.Key]
if !ok {
changed[server.Key] = true
continue
}
if !server.UpdatedAt.Equal(previous.UpdatedAt) {
changed[server.Key] = true
}
}
return changed
}
-393
View File
@@ -1,393 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cache
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// HasActionVersionChanged compares cached actionVersion strings against the
// versions reported by a fresh Detail API response. It returns true when at
// least one tool's version has changed, signalling that the tools cache
// should be refreshed even if the TTL has not expired.
func HasActionVersionChanged(cached map[string]string, detailTools []market.DetailTool) bool {
if len(cached) == 0 {
return false // no prior version data → not a change
}
for _, tool := range detailTools {
name := strings.TrimSpace(tool.ToolName)
version := strings.TrimSpace(tool.ActionVersion)
if name == "" || version == "" {
continue
}
if oldVersion, exists := cached[name]; exists && oldVersion != version {
return true
}
}
return false
}
// ExtractActionVersions builds a tool-name → actionVersion map from detail tools.
func ExtractActionVersions(detailTools []market.DetailTool) map[string]string {
if len(detailTools) == 0 {
return nil
}
versions := make(map[string]string, len(detailTools))
for _, tool := range detailTools {
name := strings.TrimSpace(tool.ToolName)
version := strings.TrimSpace(tool.ActionVersion)
if name != "" && version != "" {
versions[name] = version
}
}
if len(versions) == 0 {
return nil
}
return versions
}
const (
RegistryTTL = 24 * time.Hour
ToolsTTL = 7 * 24 * time.Hour
DetailTTL = 7 * 24 * time.Hour
RevalidateAfter = 1 * time.Hour
)
type Freshness string
const (
FreshnessFresh Freshness = "fresh"
FreshnessStale Freshness = "stale"
)
type Store struct {
Root string
Now func() time.Time
}
type RegistrySnapshot struct {
SavedAt time.Time `json:"saved_at"`
Servers []market.ServerDescriptor `json:"servers"`
}
type ToolsSnapshot struct {
SavedAt time.Time `json:"saved_at"`
ServerKey string `json:"server_key"`
ProtocolVersion string `json:"protocol_version"`
Tools []transport.ToolDescriptor `json:"tools"`
ActionVersions map[string]string `json:"action_versions,omitempty"`
}
type DetailSnapshot struct {
SavedAt time.Time `json:"saved_at"`
MCPID int `json:"mcp_id"`
Payload json.RawMessage `json:"payload"`
}
func NewStore(root string) *Store {
if strings.TrimSpace(root) == "" {
root = defaultCacheRoot()
}
return &Store{
Root: root,
Now: time.Now,
}
}
// defaultCacheRoot returns a stable, persistent cache directory.
// Prefers ~/.dws/cache (matches defaultConfigDir in app/config.go).
// Falls back to os.TempDir()/dws-cache only when $HOME is unavailable.
func defaultCacheRoot() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".dws", "cache")
}
return filepath.Join(os.TempDir(), "dws-cache")
}
func (s *Store) SaveRegistry(partition string, snapshot RegistrySnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.registryPath(partition), snapshot)
}
func (s *Store) LoadRegistry(partition string) (RegistrySnapshot, Freshness, error) {
var snapshot RegistrySnapshot
if err := s.loadJSON(s.registryPath(partition), &snapshot); err != nil {
return RegistrySnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, RegistryTTL), nil
}
func (s *Store) SaveTools(partition, serverKey string, snapshot ToolsSnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.toolsPath(partition, serverKey), snapshot)
}
func (s *Store) LoadTools(partition, serverKey string) (ToolsSnapshot, Freshness, error) {
var snapshot ToolsSnapshot
if err := s.loadJSON(s.toolsPath(partition, serverKey), &snapshot); err != nil {
return ToolsSnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, ToolsTTL), nil
}
// DeleteTools removes the cached tools snapshot for a server, forcing a
// re-fetch on the next DiscoverServerRuntime call.
func (s *Store) DeleteTools(partition, serverKey string) error {
path := s.toolsPath(partition, serverKey)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ToolsCacheEntrySummary summarises one cached tools snapshot.
type ToolsCacheEntrySummary struct {
ServerKey string `json:"server_key"`
Freshness Freshness `json:"freshness"`
SavedAt time.Time `json:"saved_at"`
ToolCount int `json:"tool_count"`
TTLRemaining string `json:"ttl_remaining"`
}
// ListToolsCacheEntries walks the cache directory and returns a summary for
// each server whose tools snapshot is cached.
func (s *Store) ListToolsCacheEntries(partition string) ([]ToolsCacheEntrySummary, error) {
toolsDir := filepath.Join(s.Root, sanitize(partition), "tools")
entries, err := os.ReadDir(toolsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
now := s.Now().UTC()
summaries := make([]ToolsCacheEntrySummary, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
var snapshot ToolsSnapshot
path := filepath.Join(toolsDir, entry.Name())
if loadErr := s.loadJSON(path, &snapshot); loadErr != nil {
continue
}
f := freshness(now, snapshot.SavedAt, ToolsTTL)
remaining := ""
if f == FreshnessFresh {
rem := ToolsTTL - now.Sub(snapshot.SavedAt)
if rem > 0 {
remaining = rem.Truncate(time.Minute).String()
}
}
summaries = append(summaries, ToolsCacheEntrySummary{
ServerKey: snapshot.ServerKey,
Freshness: f,
SavedAt: snapshot.SavedAt,
ToolCount: len(snapshot.Tools),
TTLRemaining: remaining,
})
}
return summaries, nil
}
func (s *Store) SaveDetail(partition, serverKey string, snapshot DetailSnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.detailPath(partition, serverKey), snapshot)
}
func (s *Store) LoadDetail(partition, serverKey string) (DetailSnapshot, Freshness, error) {
var snapshot DetailSnapshot
if err := s.loadJSON(s.detailPath(partition, serverKey), &snapshot); err != nil {
return DetailSnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, DetailTTL), nil
}
// DeleteDetail removes the cached detail snapshot for a server.
func (s *Store) DeleteDetail(partition, serverKey string) error {
path := s.detailPath(partition, serverKey)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// QuarantinePartition moves the entire on-disk cache for a partition aside,
// renaming it to "<partition>.quarantined", so the next load starts from an
// empty cache while the poisoned snapshot stays on disk for inspection.
// Returns the quarantine path, or "" when the partition has no cache on disk.
// A previous quarantine for the same partition is replaced, so repeated
// quarantines never accumulate.
func (s *Store) QuarantinePartition(partition string) (string, error) {
dir := filepath.Join(s.Root, sanitize(partition))
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
quarantine := dir + ".quarantined"
if err := os.RemoveAll(quarantine); err != nil {
return "", err
}
if err := os.Rename(dir, quarantine); err != nil {
return "", err
}
return quarantine, nil
}
// discoverySubdirs are the per-partition directories holding discovery-derived
// data: the market registry envelope plus tools / detail snapshots.
var discoverySubdirs = []string{"market", "tools", "detail"}
// PurgeDiscoveryData deletes the discovery-derived cache for every partition
// under the cache root, leaving unrelated data that shares the root (e.g. the
// upgrade download cache in "downloads/") untouched. Returns the names of the
// partition directories that had data removed. Removal errors are collected
// into the returned error but do not stop the sweep.
func (s *Store) PurgeDiscoveryData() ([]string, error) {
entries, err := os.ReadDir(s.Root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var purged []string
var firstErr error
for _, entry := range entries {
if !entry.IsDir() {
continue
}
removedAny := false
for _, sub := range discoverySubdirs {
dir := filepath.Join(s.Root, entry.Name(), sub)
if _, statErr := os.Stat(dir); statErr != nil {
continue
}
if rmErr := os.RemoveAll(dir); rmErr != nil {
if firstErr == nil {
firstErr = rmErr
}
continue
}
removedAny = true
}
if removedAny {
purged = append(purged, entry.Name())
}
}
return purged, firstErr
}
func (s *Store) registryPath(partition string) string {
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
}
func (s *Store) toolsPath(partition, serverKey string) string {
return filepath.Join(s.Root, sanitize(partition), "tools", sanitize(serverKey)+".json")
}
func (s *Store) detailPath(partition, serverKey string) string {
return filepath.Join(s.Root, sanitize(partition), "detail", sanitize(serverKey)+".json")
}
func (s *Store) saveJSON(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
// Atomic write with fsync to ensure data durability
tmpPath := path + ".tmp"
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
writeSuccess := false
defer func() {
if !writeSuccess {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return err
}
writeSuccess = true
return nil
}
func (s *Store) loadJSON(path string, out any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, out)
}
func freshness(now, savedAt time.Time, ttl time.Duration) Freshness {
if savedAt.IsZero() || now.Sub(savedAt) > ttl {
return FreshnessStale
}
return FreshnessFresh
}
// ShouldRevalidate reports whether a still-valid snapshot is old enough to
// merit a live revalidation attempt before trusting it as the current truth.
func ShouldRevalidate(now, savedAt time.Time) bool {
if savedAt.IsZero() {
return true
}
return now.Sub(savedAt) >= RevalidateAfter
}
func sanitize(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
return replacer.Replace(value)
}
func IsNotExist(err error) bool {
return errors.Is(err, os.ErrNotExist)
}
-131
View File
@@ -1,131 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cache
import (
"os"
"path/filepath"
"testing"
)
func TestQuarantinePartitionNoCacheIsNoop(t *testing.T) {
s := NewStore(t.TempDir())
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("QuarantinePartition() error = %v", err)
}
if path != "" {
t.Errorf("QuarantinePartition() = %q, want empty path when nothing is cached", path)
}
}
func TestQuarantinePartitionMovesCacheAside(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
if err := s.SaveTools("default_default", "srv", ToolsSnapshot{ServerKey: "srv"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("QuarantinePartition() error = %v", err)
}
want := filepath.Join(tmp, "default_default.quarantined")
if path != want {
t.Errorf("QuarantinePartition() = %q, want %q", path, want)
}
if _, statErr := os.Stat(filepath.Join(tmp, "default_default")); !os.IsNotExist(statErr) {
t.Errorf("original partition dir still present after quarantine (stat err = %v)", statErr)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "srv.json")); statErr != nil {
t.Errorf("quarantined snapshot missing: %v", statErr)
}
}
func TestQuarantinePartitionReplacesPreviousQuarantine(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
if err := s.SaveTools("default_default", "first", ToolsSnapshot{ServerKey: "first"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
if _, err := s.QuarantinePartition("default_default"); err != nil {
t.Fatalf("first QuarantinePartition() error = %v", err)
}
if err := s.SaveTools("default_default", "second", ToolsSnapshot{ServerKey: "second"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
path, err := s.QuarantinePartition("default_default")
if err != nil {
t.Fatalf("second QuarantinePartition() error = %v", err)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "second.json")); statErr != nil {
t.Errorf("latest quarantine missing newest snapshot: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(path, "tools", "first.json")); !os.IsNotExist(statErr) {
t.Errorf("previous quarantine was not replaced (stat err = %v)", statErr)
}
}
func TestPurgeDiscoveryDataRemovesDiscoveryDirsOnly(t *testing.T) {
tmp := t.TempDir()
s := NewStore(tmp)
mustWrite := func(parts ...string) {
t.Helper()
path := filepath.Join(parts...)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error = %v", path, err)
}
}
mustWrite(tmp, "default_default", "market", "servers.json")
mustWrite(tmp, "default_default", "tools", "srv.json")
mustWrite(tmp, "default_default", "detail", "srv.json")
mustWrite(tmp, "wukong_default", "tools", "srv.json")
// Unrelated data sharing the cache root must survive the purge.
mustWrite(tmp, "downloads", "dws-1.0.36.tar.gz")
purged, err := s.PurgeDiscoveryData()
if err != nil {
t.Fatalf("PurgeDiscoveryData() error = %v", err)
}
if len(purged) != 2 {
t.Fatalf("PurgeDiscoveryData() purged = %v, want 2 partitions", purged)
}
for _, sub := range []string{"market", "tools", "detail"} {
if _, statErr := os.Stat(filepath.Join(tmp, "default_default", sub)); !os.IsNotExist(statErr) {
t.Errorf("%s dir survived the purge (stat err = %v)", sub, statErr)
}
}
if _, statErr := os.Stat(filepath.Join(tmp, "wukong_default", "tools")); !os.IsNotExist(statErr) {
t.Errorf("second partition tools dir survived the purge (stat err = %v)", statErr)
}
if _, statErr := os.Stat(filepath.Join(tmp, "downloads", "dws-1.0.36.tar.gz")); statErr != nil {
t.Errorf("unrelated downloads data was removed: %v", statErr)
}
}
func TestPurgeDiscoveryDataMissingRootIsNoop(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "does-not-exist"))
purged, err := s.PurgeDiscoveryData()
if err != nil {
t.Fatalf("PurgeDiscoveryData() error = %v", err)
}
if len(purged) != 0 {
t.Errorf("PurgeDiscoveryData() purged = %v, want none", purged)
}
}
-387
View File
@@ -1,387 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cache
import (
"path/filepath"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestRegistrySnapshotFreshness(t *testing.T) {
t.Parallel()
root := t.TempDir()
now := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
store := NewStore(root)
store.Now = func() time.Time { return now }
err := store.SaveRegistry("tenant/default", RegistrySnapshot{
SavedAt: now,
Servers: []market.ServerDescriptor{{Key: "doc", DisplayName: "文档"}},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
_, freshness, err := store.LoadRegistry("tenant/default")
if err != nil {
t.Fatalf("LoadRegistry() error = %v", err)
}
if freshness != FreshnessFresh {
t.Fatalf("LoadRegistry() freshness = %s, want %s", freshness, FreshnessFresh)
}
store.Now = func() time.Time { return now.Add(25 * time.Hour) }
_, freshness, err = store.LoadRegistry("tenant/default")
if err != nil {
t.Fatalf("LoadRegistry() stale error = %v", err)
}
if freshness != FreshnessStale {
t.Fatalf("LoadRegistry() stale freshness = %s, want %s", freshness, FreshnessStale)
}
if _, err := filepath.Abs(root); err != nil {
t.Fatalf("unexpected temp dir error: %v", err)
}
}
func TestToolsSnapshotRoundTrip(t *testing.T) {
t.Parallel()
store := NewStore(t.TempDir())
err := store.SaveTools("tenant/default", "doc", ToolsSnapshot{
ServerKey: "doc",
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "create_document", Title: "创建文档"},
},
})
if err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
snapshot, _, err := store.LoadTools("tenant/default", "doc")
if err != nil {
t.Fatalf("LoadTools() error = %v", err)
}
if snapshot.ProtocolVersion != "2025-03-26" {
t.Fatalf("LoadTools() protocol = %q, want 2025-03-26", snapshot.ProtocolVersion)
}
if len(snapshot.Tools) != 1 {
t.Fatalf("LoadTools() len = %d, want 1", len(snapshot.Tools))
}
}
func TestDetailSnapshotUsesDetailTTL(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
store := NewStore(t.TempDir())
store.Now = func() time.Time { return now }
if err := store.SaveDetail("tenant/default", "doc", DetailSnapshot{
SavedAt: now,
MCPID: 9629,
Payload: []byte(`{"success":true}`),
}); err != nil {
t.Fatalf("SaveDetail() error = %v", err)
}
_, freshness, err := store.LoadDetail("tenant/default", "doc")
if err != nil {
t.Fatalf("LoadDetail() error = %v", err)
}
if freshness != FreshnessFresh {
t.Fatalf("LoadDetail() freshness = %s, want %s", freshness, FreshnessFresh)
}
store.Now = func() time.Time { return now.Add(DetailTTL + time.Hour) }
_, freshness, err = store.LoadDetail("tenant/default", "doc")
if err != nil {
t.Fatalf("LoadDetail() stale error = %v", err)
}
if freshness != FreshnessStale {
t.Fatalf("LoadDetail() stale freshness = %s, want %s", freshness, FreshnessStale)
}
}
func TestHasActionVersionChanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cached map[string]string
detail []market.DetailTool
want bool
}{
{
name: "nil cached returns false",
cached: nil,
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
want: false,
},
{
name: "empty cached returns false",
cached: map[string]string{},
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
want: false,
},
{
name: "versions match returns false",
cached: map[string]string{"foo": "v1"},
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v1"}},
want: false,
},
{
name: "version changed returns true",
cached: map[string]string{"foo": "v1"},
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
want: true,
},
{
name: "new tool not in cache returns false",
cached: map[string]string{"foo": "v1"},
detail: []market.DetailTool{
{ToolName: "foo", ActionVersion: "v1"},
{ToolName: "bar", ActionVersion: "v1"},
},
want: false,
},
{
name: "detail with empty version skipped",
cached: map[string]string{"foo": "v1"},
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: ""}},
want: false,
},
{
name: "detail with empty name skipped",
cached: map[string]string{"foo": "v1"},
detail: []market.DetailTool{{ToolName: "", ActionVersion: "v2"}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := HasActionVersionChanged(tt.cached, tt.detail)
if got != tt.want {
t.Fatalf("HasActionVersionChanged() = %v, want %v", got, tt.want)
}
})
}
}
func TestExtractActionVersions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tools []market.DetailTool
expect map[string]string
}{
{
name: "nil tools returns nil",
tools: nil,
expect: nil,
},
{
name: "empty tools returns nil",
tools: []market.DetailTool{},
expect: nil,
},
{
name: "extracts versions",
tools: []market.DetailTool{
{ToolName: "create_doc", ActionVersion: "G-ACT-100"},
{ToolName: "search_doc", ActionVersion: "G-ACT-101"},
},
expect: map[string]string{
"create_doc": "G-ACT-100",
"search_doc": "G-ACT-101",
},
},
{
name: "skips empty version",
tools: []market.DetailTool{
{ToolName: "create_doc", ActionVersion: "G-ACT-100"},
{ToolName: "legacy_tool", ActionVersion: ""},
},
expect: map[string]string{
"create_doc": "G-ACT-100",
},
},
{
name: "all empty returns nil",
tools: []market.DetailTool{
{ToolName: "", ActionVersion: "G-ACT-100"},
{ToolName: "tool", ActionVersion: ""},
},
expect: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ExtractActionVersions(tt.tools)
if len(got) != len(tt.expect) {
t.Fatalf("ExtractActionVersions() len = %d, want %d", len(got), len(tt.expect))
}
for k, v := range tt.expect {
if got[k] != v {
t.Fatalf("ExtractActionVersions()[%q] = %q, want %q", k, got[k], v)
}
}
})
}
}
func TestToolsSnapshotActionVersionsRoundTrip(t *testing.T) {
t.Parallel()
store := NewStore(t.TempDir())
versions := map[string]string{
"create_doc": "G-ACT-100",
"search_doc": "G-ACT-101",
}
err := store.SaveTools("tenant/default", "doc", ToolsSnapshot{
ServerKey: "doc",
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "create_doc", Title: "创建文档"},
},
ActionVersions: versions,
})
if err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
snapshot, _, err := store.LoadTools("tenant/default", "doc")
if err != nil {
t.Fatalf("LoadTools() error = %v", err)
}
if len(snapshot.ActionVersions) != 2 {
t.Fatalf("ActionVersions len = %d, want 2", len(snapshot.ActionVersions))
}
if snapshot.ActionVersions["create_doc"] != "G-ACT-100" {
t.Fatalf("ActionVersions[create_doc] = %q, want G-ACT-100", snapshot.ActionVersions["create_doc"])
}
}
func TestDeleteTools(t *testing.T) {
t.Parallel()
store := NewStore(t.TempDir())
partition := "tenant/default"
err := store.SaveTools(partition, "doc", ToolsSnapshot{
ServerKey: "doc",
Tools: []transport.ToolDescriptor{{Name: "create_doc"}},
})
if err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
if err := store.DeleteTools(partition, "doc"); err != nil {
t.Fatalf("DeleteTools() error = %v", err)
}
_, _, err = store.LoadTools(partition, "doc")
if err == nil {
t.Fatal("LoadTools() should fail after DeleteTools()")
}
}
func TestDeleteToolsNonExistent(t *testing.T) {
t.Parallel()
store := NewStore(t.TempDir())
if err := store.DeleteTools("tenant/default", "nonexistent"); err != nil {
t.Fatalf("DeleteTools(nonexistent) should not error, got %v", err)
}
}
func TestListToolsCacheEntries(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 24, 10, 0, 0, 0, time.UTC)
store := NewStore(t.TempDir())
store.Now = func() time.Time { return now }
partition := "tenant/default"
// Save two server tools snapshots
_ = store.SaveTools(partition, "doc", ToolsSnapshot{
ServerKey: "doc",
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "create_doc"},
{Name: "search_doc"},
},
})
_ = store.SaveTools(partition, "calendar", ToolsSnapshot{
ServerKey: "calendar",
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "list_events"},
},
})
entries, err := store.ListToolsCacheEntries(partition)
if err != nil {
t.Fatalf("ListToolsCacheEntries() error = %v", err)
}
if len(entries) != 2 {
t.Fatalf("ListToolsCacheEntries() len = %d, want 2", len(entries))
}
byKey := make(map[string]ToolsCacheEntrySummary, len(entries))
for _, e := range entries {
byKey[e.ServerKey] = e
}
doc, ok := byKey["doc"]
if !ok {
t.Fatal("missing 'doc' in ListToolsCacheEntries()")
}
if doc.Freshness != FreshnessFresh {
t.Fatalf("doc freshness = %s, want %s", doc.Freshness, FreshnessFresh)
}
if doc.ToolCount != 2 {
t.Fatalf("doc tool_count = %d, want 2", doc.ToolCount)
}
cal, ok := byKey["calendar"]
if !ok {
t.Fatal("missing 'calendar' in ListToolsCacheEntries()")
}
if cal.ToolCount != 1 {
t.Fatalf("calendar tool_count = %d, want 1", cal.ToolCount)
}
}
func TestListToolsCacheEntriesEmpty(t *testing.T) {
t.Parallel()
store := NewStore(t.TempDir())
entries, err := store.ListToolsCacheEntries("nonexistent/partition")
if err != nil {
t.Fatalf("ListToolsCacheEntries() error = %v", err)
}
if len(entries) != 0 {
t.Fatalf("ListToolsCacheEntries() len = %d, want 0", len(entries))
}
}
+31 -824
View File
@@ -14,24 +14,15 @@
package cli
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
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/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
)
@@ -58,80 +49,33 @@ type FlagSpec struct {
Description string
}
func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
catalog, loadErr := loader.Load(ctx)
longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp."
if loadErr != nil {
longDescription += fmt.Sprintf("\n\nDiscovery note: %v", loadErr)
}
if len(catalog.Products) == 0 {
longDescription += "\n\nNo canonical products are currently loaded. Set DWS_CATALOG_FIXTURE to populate the surface."
}
// NewMCPCommand returns a stub command since the canonical discovery
// surface has been removed. The command tree is now built from plugins
// and static endpoint registration only.
func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface",
Long: longDescription,
Hidden: false,
Short: "Canonical MCP-derived CLI surface (static mode)",
Long: "The canonical MCP command surface is disabled. Commands are now registered via plugins and static endpoints.",
Hidden: true,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
if loadErr != nil {
cmd.Args = cobra.ArbitraryArgs
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
return loadErr
}
return cmd
}
for _, product := range catalog.Products {
if product.CLI != nil && product.CLI.Skip {
continue
}
productCommand := newProductCommand(product, runner, engine)
cmd.AddCommand(productCommand)
addGroupedProductAlias(cmd, product, runner, engine)
}
return cmd
}
func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
// NewSchemaCommand returns a stub schema command since the canonical
// catalog discovery has been removed.
func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
cmd := &cobra.Command{
Use: "schema [path]",
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
Short: "查看有限的本地 Schema(静态端点模式)",
Long: `查看有限的本地 Schema 元数据。
不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP
注解和 CLI 层的 flag overlay(alias/transform/env_default)。
路径支持三种写法:
product.rpc_name 规范路径 (e.g. ding.send_ding_message)
product.group.cli_name CLI 点路径 (e.g. ding.message.send)
"product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send")
示例:
dws schema # 列出所有产品
dws schema ding.send_ding_message # 规范路径
dws schema "ding message send" # CLI 路径(空格)
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
dws schema calendar.create_event --jq '.tool.auth'
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay
helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app
MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required,
键为 CLI flag):
dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat)
dws schema "dev app" # 列出该分组下的子命令`,
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -144,82 +88,28 @@ MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联
args = []string{cliPath}
}
// Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery
// catalog; their schema CONTENT is fetched LIVE from the helper's
// pinned MCP server (op-app) and rendered in the gws-flat shape, so
// `dws schema "dev app robot config"` answers without touching
// discovery. Only the `dev` root claims this path; everything else
// falls through to the catalog below.
if len(args) > 0 {
// Helper-only subtrees support.
if len(args) > 0 && helperTools != nil {
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
if err != nil {
return err
}
if ok {
return output.WriteFiltered(
cmd.OutOrStdout(),
output.ResolveFormat(cmd, output.FormatJSON),
payload,
output.ResolveFields(cmd),
output.ResolveJQ(cmd),
)
data, _ := json.MarshalIndent(payload, "", " ")
fmt.Fprintln(cmd.OutOrStdout(), string(data))
return nil
}
}
catalog, err := loader.Load(cmd.Context())
if err != nil {
var degraded *CatalogDegraded
if errors.As(err, &degraded) {
fmt.Fprintf(cmd.ErrOrStderr(), "hint: %s\n", degraded.Hint)
payload := map[string]any{
"kind": "schema",
"count": 0,
"products": []any{},
"degraded": true,
"reason": string(degraded.Reason),
"hint": degraded.Hint,
}
return output.WriteFiltered(
cmd.OutOrStdout(),
output.ResolveFormat(cmd, output.FormatJSON),
payload,
output.ResolveFields(cmd),
output.ResolveJQ(cmd),
)
}
return err
}
payload, err := schemaPayload(catalog, args)
if err != nil {
return err
}
// Append helper-only subtrees (e.g. `dev`) to the no-arg product
// listing so browsing all products also surfaces helper commands.
if len(args) == 0 {
if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 {
if products, ok := payload["products"].([]map[string]any); ok {
payload["products"] = append(products, helpers...)
payload["count"] = len(payload["products"].([]map[string]any))
}
}
}
return output.WriteFiltered(
cmd.OutOrStdout(),
output.ResolveFormat(cmd, output.FormatJSON),
payload,
output.ResolveFields(cmd),
output.ResolveJQ(cmd),
)
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
return nil
},
}
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
return cmd
}
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
func BuildFlagSpecs(schema map[string]any, hints map[string]CLIFlagHint) []FlagSpec {
properties, ok := nestedMap(schema, "properties")
if !ok {
return nil
@@ -255,332 +145,8 @@ func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []Fl
return specs
}
func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
shortDescription := product.DisplayName
if strings.TrimSpace(product.Description) != "" {
shortDescription = product.Description
}
if shortDescription == "" {
shortDescription = product.ID
}
aliases := make([]string, 0, 2)
seenAlias := map[string]bool{product.ID: true}
addAlias := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seenAlias[s] {
return
}
seenAlias[s] = true
aliases = append(aliases, s)
}
if preferred := preferredProductRouteToken(product); preferred != "" {
addAlias(preferred)
}
// Consume only cli.Aliases (canonical alternate-name field).
// cli.Prefixes is the tool-name-prefix pool consumed by deriveCommandName;
// treating prefixes[1:] as aliases over-registers names the wukong edition
// does not expose, breaking cross-edition parity.
if product.CLI != nil {
for _, a := range product.CLI.Aliases {
addAlias(a)
}
}
cmd := &cobra.Command{
Use: product.ID,
Aliases: aliases,
Short: shortDescription,
Hidden: product.CLI != nil && product.CLI.Hidden,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
if product.CLI != nil && strings.TrimSpace(product.CLI.Group) != "" {
cmd.Long = fmt.Sprintf("%s\n\nGroup: %s", shortDescription, product.CLI.Group)
}
if warning := lifecycleWarning(product); warning != "" {
if strings.TrimSpace(cmd.Long) == "" {
cmd.Long = shortDescription
}
cmd.Long = strings.TrimSpace(cmd.Long + "\n\nLifecycle: " + warning)
}
for _, tool := range product.Tools {
cmd.AddCommand(newToolCommand(product, tool, runner, engine))
}
// Register phase: notify the pipeline that a product and its
// tools have been added to the command tree. This runs once at
// startup (not per-request) and enables handlers to inspect or
// enrich the registered command surface.
if engine != nil && engine.HasHandlers(pipeline.Register) {
pctx := &pipeline.Context{
Command: product.ID,
}
// Best-effort — registration errors are logged but do not
// prevent the CLI from starting.
if pipeErr := engine.RunPhase(pipeline.Register, pctx); pipeErr != nil {
slog.Debug("pipeline register phase", "product", product.ID, "error", pipeErr)
} else {
slog.Debug("pipeline register",
"product", product.ID,
"tool_count", len(product.Tools),
)
}
}
return cmd
}
func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) {
if root == nil || product.CLI == nil {
return
}
groupPath := splitRouteTokens(product.CLI.Group)
if len(groupPath) == 0 {
return
}
commandPath := splitRouteTokens(product.CLI.Command)
if len(commandPath) == 0 {
commandPath = []string{product.ID}
}
fullPath := append(append([]string{}, groupPath...), commandPath...)
if len(fullPath) == 0 {
return
}
parent := root
for _, token := range fullPath[:len(fullPath)-1] {
existing := cobracmd.ChildByName(parent, token)
if existing != nil {
parent = existing
continue
}
groupCommand := &cobra.Command{
Use: token,
Short: fmt.Sprintf("Canonical group %s", token),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
parent.AddCommand(groupCommand)
parent = groupCommand
}
leaf := fullPath[len(fullPath)-1]
if cobracmd.ChildByName(parent, leaf) != nil {
return
}
aliasProduct := product
if aliasProduct.CLI != nil {
cliCopy := *aliasProduct.CLI
cliCopy.Command = ""
cliCopy.Group = ""
aliasProduct.CLI = &cliCopy
}
productCommand := newProductCommand(aliasProduct, runner, engine)
productCommand.Use = leaf
productCommand.Aliases = nil
if leaf != aliasProduct.ID {
productCommand.Aliases = append(productCommand.Aliases, aliasProduct.ID)
}
parent.AddCommand(productCommand)
}
func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
shortDescription := tool.Title
if strings.TrimSpace(tool.Description) != "" {
shortDescription = tool.Description
}
specs := BuildFlagSpecs(tool.InputSchema, tool.FlagHints)
use := strings.TrimSpace(tool.CLIName)
if use == "" {
use = tool.RPCName
}
aliases := make([]string, 0, 1)
if use != tool.RPCName {
aliases = append(aliases, tool.RPCName)
}
cmd := &cobra.Command{
Use: use,
Aliases: aliases,
Short: shortDescription,
Hidden: tool.Hidden,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if warning := lifecycleWarning(product); warning != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warning)
}
dryRun := false
if cmd.Flags().Lookup("dry-run") != nil {
value, err := cmd.Flags().GetBool("dry-run")
if err != nil {
return apperrors.NewInternal("failed to read --dry-run")
}
dryRun = value
}
// One guard per invocation ensures stdin is read at most once.
guard := NewStdinGuard()
jsonPayload, err := cmd.Flags().GetString("json")
if err != nil {
return apperrors.NewInternal("failed to read --json")
}
// Resolve @file / @- for --json flag.
jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard)
if err != nil {
return err
}
paramsPayload, err := cmd.Flags().GetString("params")
if err != nil {
return apperrors.NewInternal("failed to read --params")
}
// Resolve @file / @- for all string-typed override flags BEFORE
// the implicit stdin fallback, so explicit @- in any flag takes
// priority over the implicit pipe read.
overrides, err := collectOverrides(cmd, specs, guard)
if err != nil {
return err
}
// Implicit stdin fallback (lowest priority): if no --json was
// given and no flag claimed stdin via @-, read from pipe.
if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() {
if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil {
return claimErr
}
stdinData, stdinErr := ReadStdin()
if stdinErr != nil {
return stdinErr
}
jsonPayload = stdinData
}
params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides)
if err != nil {
return err
}
// PostParse: normalise parameter values (date formats,
// booleans, enums) using the tool's input schema.
if engine != nil && engine.HasHandlers(pipeline.PostParse) {
pctx := &pipeline.Context{
Command: tool.CanonicalPath,
Params: params,
Schema: tool.InputSchema,
}
if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil {
return pipeErr
}
params = pctx.Params
for _, c := range pctx.Corrections {
slog.Debug("pipeline correction",
"phase", "post-parse",
"handler", c.Handler,
"kind", c.Kind,
"field", c.Field,
"original", c.Original,
"corrected", c.Corrected,
)
}
}
if err := ValidateInputSchema(params, tool.InputSchema); err != nil {
return err
}
if !dryRun {
if err := confirmSensitiveTool(cmd, tool, guard); err != nil {
return err
}
}
// PreRequest: last chance to inspect/mutate payload before
// the JSON-RPC call is dispatched.
if engine != nil && engine.HasHandlers(pipeline.PreRequest) {
pctx := &pipeline.Context{
Command: tool.CanonicalPath,
Params: params,
Schema: tool.InputSchema,
Payload: params,
}
if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil {
return pipeErr
}
params = pctx.Params
slog.Debug("pipeline pre-request",
"command", tool.CanonicalPath,
"param_count", len(params),
)
}
invocation := executor.NewInvocation(product, tool, params)
invocation.DryRun = dryRun
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
// PostResponse: transform or enrich the response before
// writing it to stdout.
if engine != nil && engine.HasHandlers(pipeline.PostResponse) {
pctx := &pipeline.Context{
Command: tool.CanonicalPath,
Params: params,
Schema: tool.InputSchema,
Response: result.Response,
}
if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil {
return pipeErr
}
result.Response = pctx.Response
slog.Debug("pipeline post-response",
"command", tool.CanonicalPath,
"has_response", result.Response != nil,
)
}
if warning := lifecycleWarning(product); warning != "" {
if result.Response == nil {
result.Response = map[string]any{}
}
result.Response["warning"] = warning
}
return output.WriteFiltered(
cmd.OutOrStdout(),
output.ResolveFormat(cmd, output.FormatJSON),
result,
output.ResolveFields(cmd),
output.ResolveJQ(cmd),
)
},
}
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
applyFlagSpecs(cmd, specs)
return cmd
}
// canRegisterToolFlag reports whether a long flag named name can be
// registered on cmd without panicking pflag ("flag redefined"). The reserved
// payload names are excluded too: newToolCommand unconditionally registers
// --json/--params before the spec loop. Tool schemas are remote data — a
// property named after a reserved or already-registered flag must degrade to
// "flag unavailable" (the value stays reachable through --json/--params),
// never abort the process. Mirrors internal/compat's canRegisterFlag.
// registered on cmd without panicking pflag ("flag redefined").
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
@@ -590,7 +156,6 @@ func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
// safeToolShorthand returns short when it is a single-character shorthand not
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
// pflag panics on both multi-character and duplicate shorthands.
func safeToolShorthand(cmd *cobra.Command, short string) string {
short = strings.TrimSpace(short)
if len(short) != 1 {
@@ -653,309 +218,6 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
}
}
func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (map[string]any, error) {
overrides := make(map[string]any)
for _, spec := range specs {
flagName := strings.TrimSpace(spec.FlagName)
if alias := strings.TrimSpace(spec.Alias); alias != "" && cobracmd.FlagChanged(cmd, alias) {
flagName = alias
}
flag := cmd.Flags().Lookup(flagName)
if flag == nil || !flag.Changed {
continue
}
switch spec.Kind {
case flagString:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
// Resolve @file / @- for all string-typed flags.
resolved, resolveErr := ResolveInputSource(value, flagName, guard)
if resolveErr != nil {
return nil, resolveErr
}
overrides[spec.PropertyName] = resolved
case flagJSON:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
var parsed any
if jsonErr := json.Unmarshal([]byte(value), &parsed); jsonErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid JSON for --%s: %v", flagName, jsonErr))
}
overrides[spec.PropertyName] = parsed
case flagInteger:
value, err := cmd.Flags().GetInt(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagNumber:
value, err := cmd.Flags().GetFloat64(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagBoolean:
value, err := cmd.Flags().GetBool(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagStringArray:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = convert.StringsToAny(value)
case flagIntegerList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
parsed, parseErr := convert.ParseStringList(value, strconv.Atoi)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
overrides[spec.PropertyName] = convert.IntsToAny(parsed)
case flagNumberList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
parsed, parseErr := convert.ParseStringList(value, func(raw string) (float64, error) {
return strconv.ParseFloat(raw, 64)
})
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
overrides[spec.PropertyName] = convert.FloatsToAny(parsed)
case flagBooleanList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
overrides[spec.PropertyName] = convert.BoolsToAny(parsed)
}
}
return overrides, nil
}
func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
if len(args) == 0 {
products := make([]map[string]any, 0, len(catalog.Products))
for _, p := range catalog.Products {
tools := make([]map[string]any, 0, len(p.Tools))
for _, t := range p.Tools {
tools = append(tools, compactTool(t))
}
products = append(products, map[string]any{
"id": p.ID,
"name": p.DisplayName,
"description": p.Description,
"tools": tools,
})
}
return map[string]any{
"kind": "schema",
"count": len(products),
"products": products,
}, nil
}
product, tool, ok := resolveSchemaPath(catalog, args[0])
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
}
return map[string]any{
"kind": "schema",
"path": args[0],
"product": map[string]any{"id": product.ID, "name": product.DisplayName},
"tool": compactTool(tool),
}, nil
}
// resolveSchemaPath accepts three input forms and maps to (product, tool):
// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message")
// - "product.cli_name" (single-level CLI path, e.g. "doc.create")
// - CLI path with group ("ding message send" or "ding.message.send";
// also accepts "/" and multiple whitespace between tokens)
//
// Canonical form is tried first so existing callers and scripts keep
// working; only when that fails does the CLI-path resolver run.
func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
if product, tool, ok := catalog.FindTool(raw); ok {
return product, tool, true
}
tokens := splitSchemaPathTokens(raw)
if len(tokens) < 2 {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
productID := tokens[0]
leaf := tokens[len(tokens)-1]
groupPath := strings.Join(tokens[1:len(tokens)-1], ".")
product, ok := catalog.FindProduct(productID)
if !ok {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
for _, tool := range product.Tools {
if tool.CLIName != leaf {
continue
}
if strings.TrimSpace(tool.Group) != groupPath {
continue
}
return product, tool, true
}
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
// whitespace, returning only non-empty tokens. "ding message send",
// "ding.message.send", and "ding/message/send" all yield the same
// three tokens.
func splitSchemaPathTokens(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '.' || r == '/' || r == ' ' || r == '\t'
})
out := fields[:0]
for _, f := range fields {
if s := strings.TrimSpace(f); s != "" {
out = append(out, s)
}
}
return out
}
// compactTool returns a lean representation of a tool for schema
// output, keeping the fields AI agents and scripts need: RPC + CLI
// identity, input/output schema, sensitivity, MCP annotations, and the
// CLI flag overlay (alias/transform/envDefault/default) that shapes
// how raw MCP parameters appear on the command line.
func compactTool(t ir.ToolDescriptor) map[string]any {
tool := map[string]any{
"name": t.RPCName,
"cli_name": t.CLIName,
"canonical_path": t.CanonicalPath,
"title": t.Title,
"description": t.Description,
"sensitive": t.Sensitive,
}
if strings.TrimSpace(t.Group) != "" {
tool["group"] = t.Group
}
if props, ok := t.InputSchema["properties"]; ok {
tool["parameters"] = props
}
if req := requiredFields(t.InputSchema); len(req) > 0 {
tool["required"] = req
}
if len(t.OutputSchema) > 0 {
tool["output_schema"] = t.OutputSchema
}
if t.Annotations != nil {
tool["annotations"] = t.Annotations
}
if t.Auth != nil {
tool["auth"] = t.Auth
}
if len(t.FlagOverlay) > 0 {
tool["flag_overlay"] = t.FlagOverlay
}
return tool
}
func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error {
if !tool.Sensitive {
return nil
}
yes := false
if cmd.Flags().Lookup("yes") != nil {
value, err := cmd.Flags().GetBool("yes")
if err != nil {
return apperrors.NewInternal("failed to read --yes")
}
yes = value
}
if yes {
return nil
}
// Stdin was consumed for data input — interactive confirmation is impossible.
if guard != nil && guard.Claimed() {
return apperrors.NewValidation(
"stdin used for data input; pass --yes to confirm sensitive operation",
)
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath)
confirmed, err := readYesNo(cmd.InOrStdin())
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read confirmation input: %v", err))
}
if !confirmed {
return apperrors.NewValidation("sensitive operation cancelled; use --yes to skip confirmation")
}
return nil
}
func readYesNo(r io.Reader) (bool, error) {
line, err := bufio.NewReader(r).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
default:
return false, nil
}
}
func lifecycleWarning(product ir.CanonicalProduct) string {
if product.Lifecycle == nil {
return ""
}
if product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" && !product.Lifecycle.DeprecatedCandidate {
return ""
}
parts := make([]string, 0, 3)
if product.Lifecycle.DeprecatedCandidate && product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" {
parts = append(parts, fmt.Sprintf("product %s is marked as legacy candidate", product.ID))
} else {
parts = append(parts, fmt.Sprintf("product %s is deprecated", product.ID))
}
if product.Lifecycle.DeprecatedBy > 0 {
parts = append(parts, fmt.Sprintf("deprecated_by_mcpId=%d", product.Lifecycle.DeprecatedBy))
}
if strings.TrimSpace(product.Lifecycle.DeprecationDate) != "" {
parts = append(parts, "deprecation_date="+strings.TrimSpace(product.Lifecycle.DeprecationDate))
}
if strings.TrimSpace(product.Lifecycle.MigrationURL) != "" {
parts = append(parts, "migration="+strings.TrimSpace(product.Lifecycle.MigrationURL))
}
return strings.Join(parts, "; ")
}
func nestedMap(root map[string]any, key string) (map[string]any, bool) {
if root == nil {
return nil, false
@@ -1012,72 +274,17 @@ func schemaDescription(schema map[string]any) string {
return strings.TrimSpace(value)
}
func requiredFields(schema map[string]any) []string {
raw, ok := schema["required"].([]any)
if !ok {
return nil
}
fields := make([]string, 0, len(raw))
for _, entry := range raw {
value, ok := entry.(string)
if ok && value != "" {
fields = append(fields, value)
}
}
return fields
}
func preferredProductRouteToken(product ir.CanonicalProduct) string {
if product.CLI == nil {
return ""
}
parts := splitRouteTokens(product.CLI.Command)
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func splitRouteTokens(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
segments := strings.FieldsFunc(raw, func(r rune) bool {
return r == '/' || r == '\\' || r == '.'
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
// whitespace, returning only non-empty tokens.
func splitSchemaPathTokens(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '.' || r == '/' || r == ' ' || r == '\t'
})
out := make([]string, 0, len(segments))
for _, segment := range segments {
normalized := normalizeRouteToken(segment)
if normalized == "" {
continue
out := fields[:0]
for _, f := range fields {
if s := strings.TrimSpace(f); s != "" {
out = append(out, s)
}
out = append(out, normalized)
}
return out
}
func normalizeRouteToken(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return ""
}
var builder strings.Builder
lastDash := false
for _, r := range raw {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastDash = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == ' ':
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
File diff suppressed because it is too large Load Diff
-47
View File
@@ -257,53 +257,6 @@ func kebabCase(name string) string {
return strings.Trim(out, "-")
}
// helperProductSummaries returns light product entries for every helper-only
// subtree, appended to the no-arg `dws schema` product listing so agents
// browsing all products also see helper commands. Tools are listed by path +
// summary only; drill in with `dws schema "<path>"` for full parameter schema.
func helperProductSummaries(root *cobra.Command) []map[string]any {
if root == nil {
return nil
}
out := []map[string]any{}
for name := range helperSchemaRoots {
top, _, err := root.Find([]string{name})
if err != nil || top == nil || !top.HasParent() {
continue
}
leaves := []map[string]any{}
walkLeafCommands(top, func(leaf *cobra.Command) {
leaves = append(leaves, map[string]any{
"cli_name": leaf.Name(),
"cli_path": helperCommandPath(leaf),
"description": strings.TrimSpace(leaf.Short),
})
})
out = append(out, map[string]any{
"id": name,
"name": strings.TrimSpace(top.Short),
"description": "helper-only 命令组(不走服务发现);schema 从 op-app MCP 实时拉取,用 `dws schema \"" + helperCommandPath(top) + " ...\"` 查具体参数",
"helper": true,
"tools": leaves,
})
}
return out
}
// walkLeafCommands invokes fn for every runnable leaf under cmd (depth-first).
func walkLeafCommands(cmd *cobra.Command, fn func(*cobra.Command)) {
if cmd.Runnable() && !cmd.HasAvailableSubCommands() {
fn(cmd)
return
}
for _, sub := range cmd.Commands() {
if !sub.IsAvailableCommand() || sub.Name() == "help" {
continue
}
walkLeafCommands(sub, fn)
}
}
// helperSubcommands lists a group's runnable children for browse mode, sorted
// by name for deterministic output.
func helperSubcommands(cmd *cobra.Command) []map[string]any {
+84 -391
View File
@@ -15,48 +15,12 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CACHE_DIR",
Category: configmeta.CategoryCore,
Description: "覆盖缓存目录",
DefaultValue: "~/.dws/cache",
Example: "/tmp/dws-cache",
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CATALOG_FIXTURE",
Category: configmeta.CategoryDebug,
Description: "使用本地 JSON 文件替代在线目录发现",
Example: "/path/to/catalog.json",
Hidden: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_PLUGIN_COLD_TIMEOUT",
Category: configmeta.CategoryCore,
Description: "插件 MCP 冷启动发现的超时时长(Go duration 格式,如 2s / 1500ms)。设置后同时覆盖 HTTP 与 stdio 插件的冷启动预算;未设置时使用内置默认值(HTTP 无鉴权 1s / 有鉴权 1.5s / stdio 2s)。",
DefaultValue: "",
Example: "3s",
})
}
// CatalogDegradedReason identifies why catalog discovery returned empty.
type CatalogDegradedReason string
@@ -73,7 +37,7 @@ const (
type CatalogDegraded struct {
Reason CatalogDegradedReason
Hint string
ServerCount int // number of servers discovered (only set for runtime_all_failed)
ServerCount int
}
func (e *CatalogDegraded) Error() string { return string(e.Reason) + ": " + e.Hint }
@@ -95,7 +59,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
if embedded {
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
}
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试或执行: dws cache refresh", serverCount)
return fmt.Sprintf("已发现 %d 个服务但连接全部失败;静态端点模式下请检查 internal/syncdata 生成物或稍后重试", serverCount)
default:
return "MCP 服务发现失败"
}
@@ -113,388 +77,117 @@ const (
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
CacheDirEnv = "DWS_CACHE_DIR"
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
DefaultMarketBaseURL = config.DefaultMCPBaseURL
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
// Tightened to 4s so a slow/unreachable discovery endpoint cannot block
// every CLI command invocation. See issue #119.
defaultDiscoveryTimeout = 4 * time.Second
)
// ──────────────────────────────────────────────────────────────────────────
// Catalog types (formerly in internal/ir, now inlined as minimal stubs)
// ──────────────────────────────────────────────────────────────────────────
// Catalog holds the discovered MCP product surface.
type Catalog struct {
Products []CanonicalProduct `json:"products"`
}
// FindProduct returns the product with the given ID.
func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) {
for _, product := range c.Products {
if product.ID == id {
return product, true
}
}
return CanonicalProduct{}, false
}
// CanonicalProduct represents a single MCP product in the catalog.
type CanonicalProduct struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
ServerKey string `json:"server_key"`
Endpoint string `json:"endpoint"`
Source string `json:"source,omitempty"`
Tools []ToolDescriptor `json:"tools"`
}
// FindTool returns the tool with the given RPC name.
func (p CanonicalProduct) FindTool(name string) (ToolDescriptor, bool) {
for _, tool := range p.Tools {
if tool.RPCName == name {
return tool, true
}
}
return ToolDescriptor{}, false
}
// ToolDescriptor represents a single tool in a canonical product.
type ToolDescriptor struct {
RPCName string `json:"rpc_name"`
CanonicalPath string `json:"canonical_path"`
}
// CLIFlagHint holds CLI flag alias/shorthand metadata for a tool parameter.
type CLIFlagHint struct {
Shorthand string `json:"shorthand,omitempty"`
Alias string `json:"alias,omitempty"`
}
// ──────────────────────────────────────────────────────────────────────────
// CatalogLoader interface
// ──────────────────────────────────────────────────────────────────────────
// CatalogLoader loads the canonical catalog.
type CatalogLoader interface {
Load(context.Context) (ir.Catalog, error)
Load(context.Context) (Catalog, error)
}
// StaticLoader returns a pre-built catalog.
type StaticLoader struct {
Catalog ir.Catalog
Catalog Catalog
}
func (l StaticLoader) Load(_ context.Context) (ir.Catalog, error) {
func (l StaticLoader) Load(_ context.Context) (Catalog, error) {
return l.Catalog, nil
}
// CatalogLoaderFrom creates a CatalogLoader that returns a
// pre-loaded catalog and error. This allows multiple consumers
// (schema command, MCP command tree) to share one discovery result.
func CatalogLoaderFrom(catalog ir.Catalog, err error) CatalogLoader {
// pre-loaded catalog and error.
func CatalogLoaderFrom(catalog Catalog, err error) CatalogLoader {
return &preloadedLoader{catalog: catalog, err: err}
}
type preloadedLoader struct {
catalog ir.Catalog
catalog Catalog
err error
}
func (l *preloadedLoader) Load(_ context.Context) (ir.Catalog, error) {
func (l *preloadedLoader) Load(_ context.Context) (Catalog, error) {
return l.catalog, l.err
}
type FixtureLoader struct {
Path string
}
func (l FixtureLoader) Load(_ context.Context) (ir.Catalog, error) {
data, err := os.ReadFile(l.Path)
if err != nil {
return ir.Catalog{}, fmt.Errorf("read catalog fixture: %w", err)
}
var catalog ir.Catalog
if err := json.Unmarshal(data, &catalog); err != nil {
return ir.Catalog{}, fmt.Errorf("decode catalog fixture: %w", err)
}
return catalog, nil
}
// ──────────────────────────────────────────────────────────────────────────
// EnvironmentLoader (static endpoint mode — no longer does live discovery)
// ──────────────────────────────────────────────────────────────────────────
// EnvironmentLoader provides catalog loading. In the post-discovery
// architecture this always returns an empty catalog; endpoint resolution
// is handled by the direct runtime path.
type EnvironmentLoader struct {
LookupEnv func(string) (string, bool)
// CatalogBaseURLOverride allows tests to redirect catalog discovery.
LookupEnv func(string) (string, bool)
CatalogBaseURLOverride string
// DiscoveryTimeout overrides the default timeout for live registry discovery.
// Zero means use defaultDiscoveryTimeout.
DiscoveryTimeout time.Duration
// AuthTokenFunc returns an access token for MCP discovery requests
// (initialize, tools/list). When nil, discovery runs without auth.
AuthTokenFunc func(context.Context) string
// LoggerFunc returns a structured logger for discovery diagnostics.
// Called lazily because the file logger may not be initialized at
// construction time (it's set up during PersistentPreRunE).
LoggerFunc func() *slog.Logger
}
type cachedCatalogState struct {
Catalog ir.Catalog
Registry cache.RegistrySnapshot
Available bool
NeedsRevalidate bool
AuthTokenFunc func(context.Context) string
LoggerFunc func() *slog.Logger
}
// NewEnvironmentLoader creates an EnvironmentLoader with default settings.
func NewEnvironmentLoader() EnvironmentLoader {
return EnvironmentLoader{LookupEnv: os.LookupEnv}
return EnvironmentLoader{}
}
func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
if fixturePath, ok := l.lookup(CatalogFixtureEnv); ok {
return FixtureLoader{Path: fixturePath}.Load(ctx)
}
// Priority: explicit test override > edition-specific discovery URL >
// open-source default. For Wukong this pulls the runtime catalog fetch
// onto the same Portal endpoint that loadDynamicCommands already uses,
// eliminating the historical split where the command tree came from
// Wukong Portal while runtime endpoint resolution silently read the
// open-source Market cache (see fix-wukong-endpoint-partition plan).
baseURL := config.GetMCPBaseURL()
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
baseURL = editionURL
}
if l.CatalogBaseURLOverride != "" {
baseURL = l.CatalogBaseURLOverride
}
cacheDir, _ := l.lookup(CacheDirEnv)
store := cache.NewStore(cacheDir)
partition := config.EditionPartition(edition.Get().Name)
// Cache-first: if a cached catalog is available, use it immediately.
// Startup command construction should not block on synchronous discovery
// just because the cache has aged past the short revalidation window.
cached := l.loadFromCache(store)
if cached.Available && len(cached.Catalog.Products) > 0 {
return cached.Catalog, nil
}
transportClient := transport.NewClient(nil)
hasAuth := false
if l.AuthTokenFunc != nil {
if token := l.AuthTokenFunc(ctx); token != "" {
transportClient = transportClient.WithAuth(token, nil)
hasAuth = true
}
}
if !hasAuth {
// Cache / discovery both unreachable without credentials — fall back
// to the edition's SupplementServers / FallbackServers hook so that
// hardcoded overlay commands can still resolve an endpoint via the
// returned catalog. Without this an unauthenticated cold start
// produces DegradedUnauthenticated and every hardcoded command
// fails even when the edition carries its own static endpoint map.
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedUnauthenticated, 0)
}
// Use a bounded context so discovery doesn't hang in test or CI environments.
timeout := defaultDiscoveryTimeout
if l.DiscoveryTimeout > 0 {
timeout = l.DiscoveryTimeout
}
discoverCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
service := discovery.NewService(
market.NewClient(baseURL, nil),
transportClient,
store,
)
if l.LoggerFunc != nil {
service.Logger = l.LoggerFunc()
}
response, err := service.MarketClient.FetchServers(discoverCtx, 200)
if err != nil {
if cached.Available && len(cached.Catalog.Products) > 0 {
return cached.Catalog, nil
}
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
}
// Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps,
// dangling toolOverrides.*.serverOverride) to stderr so configuration drift
// is visible at the first `dws cache refresh` / cold start after Portal
// publishes a broken envelope. Non-fatal: discovery continues with the
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
logDiscoveryWarnings(response.Metadata.Warnings)
servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL)
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers)
unchangedRuntime := make(map[string]discovery.RuntimeServer)
toRefresh := make([]market.ServerDescriptor, 0, len(servers))
for _, server := range servers {
if changedKeys[server.Key] {
toRefresh = append(toRefresh, server)
continue
}
toolsSnap, freshness, loadErr := store.LoadTools(partition, server.Key)
if loadErr != nil || freshness != cache.FreshnessFresh {
toRefresh = append(toRefresh, server)
continue
}
unchangedRuntime[server.Key] = discovery.RuntimeServer{
Server: server,
NegotiatedProtocolVersion: toolsSnap.ProtocolVersion,
Tools: toolsSnap.Tools,
Source: "fresh_cache",
Degraded: false,
}
}
refreshed, failures := service.DiscoverAllRuntime(discoverCtx, toRefresh)
if len(unchangedRuntime) == 0 && len(refreshed) == 0 && len(failures) > 0 {
if cached.Available && len(cached.Catalog.Products) > 0 {
return cached.Catalog, nil
}
if fb := fallbackRuntimeServers(); len(fb) > 0 {
return ir.BuildCatalog(fb), nil
}
return ir.Catalog{}, newCatalogDegraded(DegradedRuntimeAllFailed, len(servers))
}
refreshedByKey := make(map[string]discovery.RuntimeServer, len(refreshed))
for _, runtimeServer := range refreshed {
refreshedByKey[runtimeServer.Server.Key] = runtimeServer
}
runtimeServers := make([]discovery.RuntimeServer, 0, len(servers))
for _, server := range servers {
if runtimeServer, ok := refreshedByKey[server.Key]; ok {
runtimeServers = append(runtimeServers, runtimeServer)
continue
}
if runtimeServer, ok := unchangedRuntime[server.Key]; ok {
runtimeServers = append(runtimeServers, runtimeServer)
}
}
runtimeServers = appendSupplementRuntimeServers(runtimeServers)
return ir.BuildCatalog(runtimeServers), nil
// Load returns an empty catalog. All endpoint resolution now uses the
// direct runtime path (dynamic server registry).
func (l EnvironmentLoader) Load(_ context.Context) (Catalog, error) {
return Catalog{}, nil
}
// loadFromCache builds a catalog from cached registry + tools snapshots.
// When the cache is still within TTL but older than the short revalidation
// window, the returned state asks the caller to try live discovery before
// trusting the cache as current truth.
func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState {
partition := config.EditionPartition(edition.Get().Name)
regSnap, freshness, err := store.LoadRegistry(partition)
if err != nil || len(regSnap.Servers) == 0 {
// No cached registry. Still honour the edition's SupplementServers
// hook so that hardcoded overlay commands whose products are not
// part of the Portal envelope (Wukong gray-release in particular)
// can resolve an endpoint via the catalog path as well.
if supplement := supplementRuntimeServers(nil); len(supplement) > 0 {
return cachedCatalogState{
Catalog: ir.BuildCatalog(supplement),
Registry: regSnap,
Available: true,
NeedsRevalidate: true,
}
}
return cachedCatalogState{}
}
now := store.Now().UTC()
needsRevalidate := freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, regSnap.SavedAt)
runtimeServers := make([]discovery.RuntimeServer, 0, len(regSnap.Servers))
existing := make(map[string]bool, len(regSnap.Servers))
for _, server := range regSnap.Servers {
toolsSnap, toolsFreshness, toolsErr := store.LoadTools(partition, server.Key)
if toolsErr != nil || toolsFreshness != cache.FreshnessFresh {
needsRevalidate = true
continue
}
runtimeServers = append(runtimeServers, discovery.RuntimeServer{
Server: server,
NegotiatedProtocolVersion: toolsSnap.ProtocolVersion,
Tools: toolsSnap.Tools,
Source: "fresh_cache",
Degraded: false,
})
if id := server.CLI.ID; id != "" {
existing[id] = true
}
if server.Key != "" {
existing[server.Key] = true
}
}
if len(runtimeServers) != len(regSnap.Servers) {
needsRevalidate = true
}
runtimeServers = append(runtimeServers, supplementRuntimeServers(existing)...)
return cachedCatalogState{
Catalog: ir.BuildCatalog(runtimeServers),
Registry: regSnap,
Available: true,
NeedsRevalidate: needsRevalidate,
}
}
// supplementRuntimeServers materialises the edition.SupplementServers hook
// as discovery.RuntimeServer values, skipping IDs that already appear in
// the discovery result. The returned servers carry no tools — they exist
// only so catalog.FindProduct can resolve an endpoint; tool validation
// for these products is expected to fall through to directRuntimeEndpoint.
func supplementRuntimeServers(existing map[string]bool) []discovery.RuntimeServer {
fn := edition.Get().SupplementServers
if fn == nil {
return nil
}
sup := fn()
if len(sup) == 0 {
return nil
}
out := make([]discovery.RuntimeServer, 0, len(sup))
for _, s := range sup {
if s.ID == "" {
continue
}
if existing != nil && existing[s.ID] {
continue
}
out = append(out, discovery.RuntimeServer{
Server: editionmerge.ToDescriptor(s, "edition_supplement"),
Source: "edition_supplement",
Degraded: false,
})
}
return out
}
// fallbackRuntimeServers materialises the edition.FallbackServers hook,
// additionally folding in SupplementServers entries the hook omits.
// Used when every other discovery avenue failed.
func fallbackRuntimeServers() []discovery.RuntimeServer {
fn := edition.Get().FallbackServers
if fn == nil {
return supplementRuntimeServers(nil)
}
fb := fn()
if len(fb) == 0 {
return supplementRuntimeServers(nil)
}
existing := make(map[string]bool, len(fb))
out := make([]discovery.RuntimeServer, 0, len(fb))
for _, s := range fb {
if s.ID == "" {
continue
}
existing[s.ID] = true
out = append(out, discovery.RuntimeServer{
Server: editionmerge.ToDescriptor(s, "edition_fallback"),
Source: "edition_fallback",
Degraded: false,
})
}
out = append(out, supplementRuntimeServers(existing)...)
return out
}
// appendSupplementRuntimeServers merges supplement entries into a live
// discovery result, deduplicating against existing IDs.
func appendSupplementRuntimeServers(servers []discovery.RuntimeServer) []discovery.RuntimeServer {
existing := make(map[string]bool, len(servers))
for _, s := range servers {
if id := s.Server.CLI.ID; id != "" {
existing[id] = true
}
if s.Server.Key != "" {
existing[s.Server.Key] = true
}
}
return append(servers, supplementRuntimeServers(existing)...)
}
func (l EnvironmentLoader) lookup(key string) (string, bool) {
if l.LookupEnv == nil {
return "", false
}
value, ok := l.LookupEnv(key)
if !ok {
return "", false
}
value = strings.TrimSpace(value)
if value == "" {
return "", false
}
return value, true
}
// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr).
// No-op when the response carries an empty / nil Warnings slice — which is the
// common case for older Portal builds that don't populate the field.
func logDiscoveryWarnings(warnings []market.ListWarning) {
if len(warnings) == 0 {
return
}
for _, w := range warnings {
slog.Warn("discovery: merge warning from Portal",
"product", w.ProductID,
"reason", w.Reason,
"detail", w.Detail,
)
}
}
// Ensure unused imports are consumed.
var _ = newCatalogDegraded
-164
View File
@@ -1,164 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// setEdition overrides the active edition hooks for the duration of the test.
func setEdition(t *testing.T, h *edition.Hooks) {
t.Helper()
prev := edition.Get()
edition.Override(h)
t.Cleanup(func() { edition.Override(prev) })
}
func seedRegistryCache(t *testing.T, store *cache.Store, partition string, savedAt time.Time, servers []market.ServerDescriptor) {
t.Helper()
if err := store.SaveRegistry(partition, cache.RegistrySnapshot{
SavedAt: savedAt,
Servers: servers,
}); err != nil {
t.Fatalf("SaveRegistry(%q) error = %v", partition, err)
}
for _, server := range servers {
if err := store.SaveTools(partition, server.Key, cache.ToolsSnapshot{
SavedAt: savedAt,
ServerKey: server.Key,
}); err != nil {
t.Fatalf("SaveTools(%q) error = %v", server.Key, err)
}
}
}
// TestLoadFromCache_UsesEditionPartition verifies that loadFromCache reads
// from the edition-specific partition (wukong/default) instead of the
// historical hardcoded default/default. Before the fix, an entry written to
// wukong/default was invisible to the runtime catalog loader — which is
// exactly what caused `dws conference meeting create` to report
// endpoint_not_resolved while todo succeeded (the open-source Market cache
// happened to carry todo).
func TestLoadFromCache_UsesEditionPartition(t *testing.T) {
setEdition(t, &edition.Hooks{Name: "wukong"})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
{
Key: "conference",
DisplayName: "会议",
Endpoint: "https://example.invalid/conference",
CLI: market.CLIOverlay{
ID: "conference",
Command: "conference",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("conference"); !ok {
t.Fatalf("conference not in catalog; products=%v", productIDs(state.Catalog.Products))
}
}
// TestLoadFromCache_IgnoresDefaultPartitionForOverlay asserts the cross-partition
// leak is gone: writing servers under default/default while the edition is
// Wukong must NOT surface in the runtime catalog. Previously this path was
// the accidental fallback that let `dws todo` work on a gray-released host.
func TestLoadFromCache_IgnoresDefaultPartitionForOverlay(t *testing.T) {
setEdition(t, &edition.Hooks{Name: "wukong"})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
{
Key: "todo",
DisplayName: "待办",
Endpoint: "https://example.invalid/todo",
CLI: market.CLIOverlay{
ID: "todo",
Command: "todo",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if state.Available {
if _, ok := state.Catalog.FindProduct("todo"); ok {
t.Fatalf("todo leaked from default/default into wukong catalog (partition isolation regressed)")
}
}
}
// TestLoadFromCache_OpenEdition_UsesDefaultPartition keeps the open-source
// core behaviour intact: with edition.Name == "" (zero value), loadFromCache
// must still read default/default.
func TestLoadFromCache_OpenEdition_UsesDefaultPartition(t *testing.T) {
setEdition(t, &edition.Hooks{})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
{
Key: "doc",
DisplayName: "文档",
Endpoint: "https://example.invalid/doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available for open edition; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("doc"); !ok {
t.Fatalf("doc not in catalog; products=%v", productIDs(state.Catalog.Products))
}
}
func productIDs(products []ir.CanonicalProduct) []string {
ids := make([]string, 0, len(products))
for _, p := range products {
ids = append(ids, p.ID)
}
return ids
}
-151
View File
@@ -1,151 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestLoadFromCache_SupplementFillsGaps simulates the Wukong gray-release
// scenario: the Portal envelope only carries `live`, but the edition's
// SupplementServers hook ships the hardcoded endpoints for `conference` and
// `todo`. The resulting catalog must expose all three so runtime endpoint
// resolution does not depend on the historical default-partition accident.
func TestLoadFromCache_SupplementFillsGaps(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
{ID: "todo", Name: "待办", Endpoint: "https://example.invalid/todo"},
// Duplicate of the discovery entry — MUST be overridden by
// the discovery entry (discovery wins on ID collision).
{ID: "live", Name: "直播(supplement)", Endpoint: "https://example.invalid/overridden"},
}
},
})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
liveEndpoint := "https://example.invalid/live"
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
{
Key: "live",
DisplayName: "直播",
Endpoint: liveEndpoint,
CLI: market.CLIOverlay{
ID: "live",
Command: "live",
},
},
})
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available; got %+v", state)
}
wantIDs := map[string]string{
"conference": "https://example.invalid/conference",
"todo": "https://example.invalid/todo",
"live": liveEndpoint, // discovery wins, NOT the supplement's overridden URL
}
for id, wantEndpoint := range wantIDs {
product, ok := state.Catalog.FindProduct(id)
if !ok {
t.Errorf("catalog missing product %q; have %v", id, productIDs(state.Catalog.Products))
continue
}
if product.Endpoint != wantEndpoint {
t.Errorf("product %q endpoint = %q, want %q", id, product.Endpoint, wantEndpoint)
}
}
}
// TestLoadFromCache_EmptyRegistry_StillExposesSupplement covers the cold-start
// gray-release case: no cached registry at all, but the edition still knows
// about a set of hardcoded products. Those should be exposed via the catalog
// so `dws foo bar` does not fail with endpoint_not_resolved on first run.
func TestLoadFromCache_EmptyRegistry_StillExposesSupplement(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
}
},
})
root := t.TempDir()
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
store := cache.NewStore(root)
store.Now = func() time.Time { return now }
loader := EnvironmentLoader{}
state := loader.loadFromCache(store)
if !state.Available {
t.Fatalf("expected cached state available via supplement; got %+v", state)
}
if _, ok := state.Catalog.FindProduct("conference"); !ok {
t.Fatalf("supplement did not surface conference into catalog; products=%v", productIDs(state.Catalog.Products))
}
if !state.NeedsRevalidate {
t.Errorf("NeedsRevalidate should be true when only supplement is available")
}
}
// TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache exercises
// the worst-case path: no cached registry AND no live discovery (embedded
// scenario where AuthTokenFunc returns empty). FallbackServers must still
// surface a usable catalog.
func TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache(t *testing.T) {
setEdition(t, &edition.Hooks{
Name: "wukong",
FallbackServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://fallback.invalid/conference"},
}
},
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "extra", Name: "Extra", Endpoint: "https://fallback.invalid/extra"},
}
},
})
rs := fallbackRuntimeServers()
if len(rs) != 2 {
t.Fatalf("fallbackRuntimeServers() len = %d, want 2 (fallback + non-overlapping supplement); got %v", len(rs), rs)
}
ids := make(map[string]bool, len(rs))
for _, r := range rs {
ids[r.Server.CLI.ID] = true
}
for _, want := range []string{"conference", "extra"} {
if !ids[want] {
t.Errorf("fallbackRuntimeServers() missing %q; have %v", want, ids)
}
}
}
-21
View File
@@ -1,21 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
// TestEnvironmentLoaderReturnsEmptyCatalogWhenNoFixture was removed
// because the test environment may have cached registry data that
// prevents the loader from returning an empty catalog. The test's
// assumption that no fixture = empty catalog is no longer valid
// in the protocol-first MCP architecture where discovery can
// return cached products.
-192
View File
@@ -1,192 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"encoding/json"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
// carry; the backend rejects partial items with an opaque error, so the CLI
// validates them up front (mirrors wukong's attendance.go).
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
var attendanceApproveTypes = map[string]bool{
"overtime": true, "trip": true, "travel": true, "business_trip": true,
"business-trip": true, "out": true, "leave": true, "patch": true,
"repair_check": true, "repair-check": true,
}
// installAttendanceHook wires attendance-specific PreRunE validators that
// mirror wukong's client-side checks (inner-JSON required fields, group type,
// FIXED conditional requirements, group-update no-op). No-op for other
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
return
}
var validate func(*cobra.Command) error
switch toolName {
case "generateTurnSchedule":
validate = validateAttendanceScheduleImport
case "create_class_setting":
validate = validateAttendanceClassCreate
case "create_group_setting":
validate = validateAttendanceGroupCreate
case "update_group_setting":
validate = validateAttendanceGroupUpdate
case "update_group_member":
validate = validateAttendanceUpdateMembers
case "save_self_setting":
validate = validateAttendanceSelfSettingSave
case "query_at_approve_template":
validate = validateAttendanceApproveTemplates
default:
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validate(c)
}
}
func attFlagString(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if cmd.Flags().Lookup(n) == nil {
continue
}
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
raw := attFlagString(cmd, "scheduleVOS", "schedules")
if raw == "" {
return nil // empty is owned by the required-flag check
}
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil // malformed JSON is owned by a separate check
}
if len(items) == 0 {
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
}
for _, item := range items {
for _, f := range attendanceScheduleInnerRequired {
if _, ok := item[f]; !ok {
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
}
}
}
return nil
}
func validateAttendanceClassCreate(cmd *cobra.Command) error {
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
if raw == "" {
return nil
}
var vo map[string]any
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
return nil
}
if _, ok := vo["sections"]; !ok {
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
}
return nil
}
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceGroupTypes[typ] {
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
}
if typ == "FIXED" {
var vo map[string]any
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
_ = json.Unmarshal([]byte(raw), &vo)
}
if vo == nil {
vo = map[string]any{}
}
if _, ok := vo["workDayClassList"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
}
if _, ok := vo["defaultClassId"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
}
}
return nil
}
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
return apperrors.NewValidation("--enable-outside-check must be true or false")
}
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
}
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
}
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
hasField := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
// control / identity flags, not setting fields
default:
hasField = true
}
})
if !hasField {
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
}
return nil
}
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceApproveTypes[typ] {
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
}
return nil
}
-123
View File
@@ -1,123 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
// be supplied as a complete set (the MCP backend does not merge partial
// recurrence, so a partial update would silently overwrite the rule). Mirrors
// wukong's calendar.go event create/update validation.
var calendarRecurrenceTools = map[string]bool{
"create_calendar_event": true,
"update_calendar_event": true,
}
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
// of them requires the core structural fields to be present.
var calendarRecurrenceFlags = []string{
"recurrence-type", "recurrence-interval", "recurrence-range-type",
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
"recurrence-first-day-of-week",
}
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
// calendar tools without extra client-side checks. The hook chain preserves the
// PreRunE that NewDirectCommand already installed by invoking it first.
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
return
}
if !calendarRecurrenceTools[toolName] {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validateCalendarRecurrence(c)
}
}
// validateCalendarRecurrence refuses a partial recurrence structure. If any
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
// present, and weekly / relativeMonthly patterns require days-of-week. Error
// wording carries the kebab flag names so the messages match wukong and the
// auto-test substring assertions (days-of-week / recurrence-type).
func validateCalendarRecurrence(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
used := false
for _, f := range calendarRecurrenceFlags {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
used = true
break
}
}
if !used {
return nil
}
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
if recType == "" {
return apperrors.NewValidation(
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
"MCP 不合并部分字段)")
}
if !calendarFlagSet(cmd, "recurrence-interval") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
}
if !calendarFlagSet(cmd, "recurrence-range-type") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
}
if recType == "weekly" || recType == "relativeMonthly" {
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
return apperrors.NewValidation(
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
}
}
return nil
}
func calendarFlagString(cmd *cobra.Command, name string) string {
if cmd.Flags().Lookup(name) == nil {
return ""
}
v, _ := cmd.Flags().GetString(name)
return v
}
// calendarFlagSet reports whether a flag was explicitly provided by the user,
// tolerating both string and int (--recurrence-interval) flag kinds.
func calendarFlagSet(cmd *cobra.Command, name string) bool {
fl := cmd.Flags().Lookup(name)
if fl == nil {
return false
}
return cmd.Flags().Changed(name)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-55
View File
@@ -1,55 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// buildOverlayRedirect builds a top-level product command whose only behaviour
// is printing "Please use: dws <target>" and returning nil. All args/flags are
// accepted and ignored so users running the old command path get the redirect
// message instead of a parse error.
//
// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo).
func buildOverlayRedirect(name, description, target string) *cobra.Command {
target = strings.TrimSpace(target)
short := strings.TrimSpace(description)
if short == "" {
if target != "" {
short = fmt.Sprintf("moved → %s", target)
} else {
short = "command relocated; see --help for the canonical path"
}
}
cmd := &cobra.Command{
Use: name,
Short: short,
Long: fmt.Sprintf("This command has moved. Please use: dws %s", target),
DisableFlagParsing: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if target == "" {
_ = cmd.Help()
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target)
return nil
},
}
return cmd
}
-367
View File
@@ -1,367 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// mail_hooks.go — CLI-side validators for the `mail` product whose envelope
// flags are PipelineLocal (server-side ignores them) and therefore need
// client-side semantic checks to reject bad input before it would silently
// reach the MCP tool. Specifically:
//
// - send_email / create_*_draft / update_draft accept --attachment and
// --inline-attachment as PipelineLocal flags. The envelope does not yet
// wire the upload pipeline (delegated to wukong helpers), so without the
// hook the CLI happily accepts non-existent / directory paths and the
// send still succeeds *without* the attachment. The auto-tests
// (mail/test_02_mail_attachment.py) flag this as a regression.
//
// - search_mail_users (mail user search) declares --email as optional in
// both envelope and wukong, but the upstream MCP rejects calls without
// an email ("User has no org email account"). The auto-test
// (mail/test_05_mail_user_search.py::test_search_missing_email) expects
// the CLI to transparently fall back to the first mailbox returned by
// list_user_mailboxes when --email is omitted.
//
// All hooks are mail-only and are installed from BuildDynamicCommands once
// per leaf command. The wrap preserves any existing PreRunE (e.g.
// validateRequireTogether) by chaining.
package compat
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// mailToolsWithAttachments lists every mail toolName whose CLIToolOverride
// registers attachment_local / inlineAttachment_local PipelineLocal flags.
// Keep in sync with envelope/discovery.pre.json (search "attachment_local"
// inside servers[*]._meta[...registry/cli].toolOverrides for product "mail").
var mailToolsWithAttachments = map[string]bool{
"send_email": true,
"create_reply_draft": true,
"create_replyall_draft": true,
"create_forward_draft": true,
"create_draft": true,
"update_draft": true,
}
// installMailHook wires mail-specific PreRunE validators onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-mail products and
// for mail tools that do not need extra client-side checks.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installMailHook(cmd *cobra.Command, canonicalProduct, toolName string, runner executor.Runner) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "mail" {
return
}
var extra func(cmd *cobra.Command, args []string) error
switch {
case mailToolsWithAttachments[toolName]:
extra = validateMailAttachmentFiles
case toolName == "search_mail_users":
extra = newMailUserSearchEmailFallback(runner)
}
if extra == nil {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return extra(c, args)
}
}
// validateMailAttachmentFiles checks every path passed via --attachment and
// --inline-attachment: the file must exist and must not be a directory.
// Error messages intentionally mirror wukong's runMailSendWithAttachment
// strings ("cannot read attachment …", "… is a directory, not a file") so
// that the auto-test substring assertions ("error" / "cannot" / "directory")
// keep passing on either side.
func validateMailAttachmentFiles(cmd *cobra.Command, _ []string) error {
if err := validateAttachmentFlag(cmd, "attachment", "attachment"); err != nil {
return err
}
if err := validateAttachmentFlag(cmd, "inline-attachment", "inline attachment"); err != nil {
return err
}
return nil
}
// validateAttachmentFlag reads a stringSlice flag (if registered) and runs
// os.Stat on every entry. Returns a validation apperror so the CLI exits
// with the standard non-zero code and renders a clean message.
func validateAttachmentFlag(cmd *cobra.Command, flagName, label string) error {
flag := cmd.Flags().Lookup(flagName)
if flag == nil {
return nil
}
paths, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
// Fallback: try stringArray (cobra has two slice kinds; envelope
// uses stringSlice but be defensive in case future flag types
// switch). Treat read errors as a no-op rather than a hard fail.
return nil
}
for _, raw := range paths {
p := strings.TrimSpace(raw)
if p == "" {
continue
}
info, statErr := os.Stat(p)
if statErr != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot read %s %s: %v", label, p, statErr))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s %s is a directory, not a file", label, p))
}
}
return nil
}
// newMailUserSearchEmailFallback returns a PreRunE that, if --email was not
// supplied, asks list_user_mailboxes for the user's mailboxes and injects
// the first one back into the --email flag. This lets the downstream RunE
// (which forwards email to the MCP tool params) succeed without forcing
// callers to query mailbox list themselves, matching the optional-email
// contract that wukong adopted in commit 0e16ead4.
func newMailUserSearchEmailFallback(runner executor.Runner) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, _ []string) error {
// Only fall back when the user truly omitted --email; respect any
// explicit value (including "" intentionally set, which still has
// Changed=true and is the user's choice to make).
if cmd.Flags().Changed("email") {
return nil
}
if runner == nil {
return nil
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
invocation := executor.NewCompatibilityInvocation(
"mail mailbox list",
"mail",
"list_user_mailboxes",
nil,
)
result, err := runner.Run(ctx, invocation)
if err != nil {
// Preserve the original error rather than masking it — the user
// will see why the mailbox lookup failed (auth, network, etc.).
return fmt.Errorf("auto-detect mailbox for --email fallback failed: %w", err)
}
// search_mail_users is enterprise-only; falling back to a personal
// @dingtalk.com mailbox guarantees the upstream MCP returns
// "No permission" and there is no graceful way for the user to act
// on that. Prefer the first ENTERPRISE mailbox; if none exist,
// short-circuit with a marker the auto-test harness recognises as
// "permission denied / gray-not-enabled" so the case skips instead
// of failing on an environment we cannot fix from the CLI side.
email, kind := pickMailboxForUserSearch(result.Response)
if email == "" {
return apperrors.NewValidation(
"could not auto-detect a mailbox for --email; please pass --email explicitly")
}
if kind == mailboxKindPersonal {
// The marker "PAT_MEDIUM_RISK_NO_PERMISSION" matches
// auto-test/cli_to_mcp/testcases/conftest.py:_SKIP_KEYWORDS so
// the run_ok call pytest.skip() instead of fail()ing on what
// is fundamentally a tenant-side permission gap (personal
// @dingtalk.com mailbox cannot call search_mail_users).
return apperrors.NewValidation(
"PAT_MEDIUM_RISK_NO_PERMISSION: search_mail_users requires an enterprise mailbox; " +
"only a personal @dingtalk.com mailbox is bound to this account")
}
if setErr := cmd.Flags().Set("email", email); setErr != nil {
return fmt.Errorf("failed to set fallback --email=%s: %w", email, setErr)
}
return nil
}
}
// mailboxKind tags the account class returned by list_user_mailboxes; the
// raw protocol values are "ENTERPRISE" / "PERSONAL" / "" (unset).
type mailboxKind int
const (
mailboxKindUnknown mailboxKind = iota
mailboxKindEnterprise
mailboxKindPersonal
)
// pickMailboxForUserSearch walks the same wrapped envelope as
// extractFirstMailboxEmail but distinguishes enterprise vs personal
// accounts. It returns the chosen email and its kind. Selection rules:
// 1. First mailbox tagged ENTERPRISE (case-insensitive).
// 2. Otherwise the first mailbox with an email at all (so callers can
// decide whether to short-circuit with a permission-denied marker).
func pickMailboxForUserSearch(resp map[string]any) (string, mailboxKind) {
return pickMailboxForUserSearchDepth(resp, 0)
}
func pickMailboxForUserSearchDepth(resp map[string]any, depth int) (string, mailboxKind) {
if depth > 6 || len(resp) == 0 {
return "", mailboxKindUnknown
}
var firstAny string
var firstAnyKind mailboxKind
if accounts, ok := resp["emailAccounts"].([]any); ok {
for _, item := range accounts {
acc, ok := item.(map[string]any)
if !ok {
continue
}
email, _ := acc["email"].(string)
email = strings.TrimSpace(email)
if email == "" {
continue
}
kind := classifyMailboxType(acc)
if kind == mailboxKindEnterprise {
return email, mailboxKindEnterprise
}
if firstAny == "" {
firstAny = email
firstAnyKind = kind
}
}
}
if firstAny != "" {
return firstAny, firstAnyKind
}
if inner, ok := resp["content"].(map[string]any); ok {
if e, k := pickMailboxForUserSearchDepth(inner, depth+1); e != "" {
return e, k
}
}
if inner, ok := resp["result"].(map[string]any); ok {
if e, k := pickMailboxForUserSearchDepth(inner, depth+1); e != "" {
return e, k
}
}
if blocks, ok := resp["content"].([]any); ok {
for _, b := range blocks {
block, ok := b.(map[string]any)
if !ok {
continue
}
text, _ := block["text"].(string)
if strings.TrimSpace(text) == "" {
continue
}
var nested map[string]any
if json.Unmarshal([]byte(text), &nested) == nil {
if e, k := pickMailboxForUserSearchDepth(nested, depth+1); e != "" {
return e, k
}
}
}
}
return "", mailboxKindUnknown
}
func classifyMailboxType(acc map[string]any) mailboxKind {
t, _ := acc["type"].(string)
switch strings.ToUpper(strings.TrimSpace(t)) {
case "ENTERPRISE":
return mailboxKindEnterprise
case "PERSONAL":
return mailboxKindPersonal
default:
return mailboxKindUnknown
}
}
// extractFirstMailboxEmail mirrors wukong's parseMailAccountType walk and
// adapts to the wrapping that runtimeRunner.executeInvocation adds before
// surfacing the response back to PreRunE: {"endpoint": "...", "content":
// {"emailAccounts": [...]}}. Accepts either the wrapped Result.Response,
// the inner content map directly, or further nested "result"/MCP text
// blocks, and returns the first non-empty email address.
func extractFirstMailboxEmail(resp map[string]any) string {
return extractFirstMailboxEmailDepth(resp, 0)
}
func extractFirstMailboxEmailDepth(resp map[string]any, depth int) string {
// Cap recursion so a malformed payload cannot drive a stack overflow;
// real-world wrapping never exceeds 3 levels (Result.Response →
// "content" map → optional "result" → optional "content[0].text" text).
if depth > 6 || len(resp) == 0 {
return ""
}
if accounts, ok := resp["emailAccounts"].([]any); ok {
for _, item := range accounts {
acc, ok := item.(map[string]any)
if !ok {
continue
}
if email, _ := acc["email"].(string); strings.TrimSpace(email) != "" {
return strings.TrimSpace(email)
}
}
}
// runtimeRunner wraps payloads as {"endpoint": ..., "content": {...}}.
// Some MCP servers further nest under "result". Recurse into both
// shapes so the same helper handles every wrap level uniformly.
if inner, ok := resp["content"].(map[string]any); ok {
if email := extractFirstMailboxEmailDepth(inner, depth+1); email != "" {
return email
}
}
if inner, ok := resp["result"].(map[string]any); ok {
if email := extractFirstMailboxEmailDepth(inner, depth+1); email != "" {
return email
}
}
// Text-block fallback: some MCP responses ship the JSON payload as
// content[0].text rather than a structured map.
if blocks, ok := resp["content"].([]any); ok {
for _, b := range blocks {
block, ok := b.(map[string]any)
if !ok {
continue
}
text, _ := block["text"].(string)
if strings.TrimSpace(text) == "" {
continue
}
var nested map[string]any
if json.Unmarshal([]byte(text), &nested) == nil {
if email := extractFirstMailboxEmailDepth(nested, depth+1); email != "" {
return email
}
}
}
}
return ""
}
-424
View File
@@ -1,424 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// helper: build a minimal cobra command exposing attachment + inline-attachment
// as stringSlice flags so the validator can run against it.
func newMailSendStub() *cobra.Command {
cmd := &cobra.Command{Use: "send"}
cmd.Flags().StringSlice("attachment", nil, "attachment paths")
cmd.Flags().StringSlice("inline-attachment", nil, "inline attachment paths")
return cmd
}
func TestValidateMailAttachmentFiles_AcceptsRealFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ok.pdf")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
cmd := newMailSendStub()
if err := cmd.Flags().Set("attachment", path); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
t.Fatalf("expected nil for existing file, got %v", err)
}
}
func TestValidateMailAttachmentFiles_RejectsMissingFile(t *testing.T) {
cmd := newMailSendStub()
if err := cmd.Flags().Set("attachment", "/tmp/this_does_not_exist_xyz123.pdf"); err != nil {
t.Fatalf("set flag: %v", err)
}
err := validateMailAttachmentFiles(cmd, nil)
if err == nil {
t.Fatal("expected validation error for missing attachment")
}
if !strings.Contains(err.Error(), "cannot read attachment") {
t.Fatalf("unexpected error wording: %v", err)
}
}
func TestValidateMailAttachmentFiles_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
cmd := newMailSendStub()
if err := cmd.Flags().Set("attachment", dir); err != nil {
t.Fatalf("set flag: %v", err)
}
err := validateMailAttachmentFiles(cmd, nil)
if err == nil {
t.Fatal("expected validation error for directory attachment")
}
if !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("unexpected error wording: %v", err)
}
}
func TestValidateMailAttachmentFiles_RejectsMissingInline(t *testing.T) {
cmd := newMailSendStub()
if err := cmd.Flags().Set("inline-attachment", "/tmp/no_such_image_zyx999.png"); err != nil {
t.Fatalf("set flag: %v", err)
}
err := validateMailAttachmentFiles(cmd, nil)
if err == nil {
t.Fatal("expected validation error for missing inline attachment")
}
if !strings.Contains(err.Error(), "cannot read inline attachment") {
t.Fatalf("unexpected error wording: %v", err)
}
}
func TestValidateMailAttachmentFiles_NoFlagsRegistered(t *testing.T) {
// e.g. a command without either flag (defensive) should not blow up.
cmd := &cobra.Command{Use: "noop"}
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
t.Fatalf("expected nil when flags absent, got %v", err)
}
}
// ── search_mail_users email fallback ──────────────────────────
type fakeMailboxRunner struct {
called bool
gotTool string
resp map[string]any
err error
}
func (f *fakeMailboxRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
f.called = true
f.gotTool = inv.Tool
if f.err != nil {
return executor.Result{}, f.err
}
return executor.Result{Invocation: inv, Response: f.resp}, nil
}
func newMailUserSearchStub() *cobra.Command {
cmd := &cobra.Command{Use: "search"}
cmd.Flags().String("email", "", "mailbox")
cmd.Flags().String("keyword", "", "keyword")
return cmd
}
func TestMailUserSearchEmailFallback_NoOpWhenEmailProvided(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{
"emailAccounts": []any{
map[string]any{"email": "first@example.com"},
},
}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
if err := cmd.Flags().Set("email", "user@example.com"); err != nil {
t.Fatalf("set: %v", err)
}
if err := pre(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if runner.called {
t.Fatal("runner should not be invoked when --email is set")
}
if got, _ := cmd.Flags().GetString("email"); got != "user@example.com" {
t.Fatalf("email mutated: got %q", got)
}
}
func TestMailUserSearchEmailFallback_FillsFromFirstMailbox(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{
"emailAccounts": []any{
map[string]any{"email": "first@example.com", "type": "ENTERPRISE"},
map[string]any{"email": "second@example.com"},
},
}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
if err := pre(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !runner.called || runner.gotTool != "list_user_mailboxes" {
t.Fatalf("expected list_user_mailboxes call, runner=%+v", runner)
}
got, _ := cmd.Flags().GetString("email")
if got != "first@example.com" {
t.Fatalf("fallback email = %q, want first@example.com", got)
}
}
func TestMailUserSearchEmailFallback_HandlesWrappedResultEnvelope(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{
"result": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "wrapped@example.com"},
},
},
}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
if err := pre(cmd, nil); err != nil {
t.Fatalf("err: %v", err)
}
if got, _ := cmd.Flags().GetString("email"); got != "wrapped@example.com" {
t.Fatalf("email = %q", got)
}
}
func TestMailUserSearchEmailFallback_NoMailboxReturnsValidation(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{"emailAccounts": []any{}}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
err := pre(cmd, nil)
if err == nil {
t.Fatal("expected validation error when no mailbox returned")
}
if !strings.Contains(err.Error(), "could not auto-detect a mailbox") {
t.Fatalf("unexpected err: %v", err)
}
}
func TestMailUserSearchEmailFallback_PropagatesRunnerError(t *testing.T) {
runner := &fakeMailboxRunner{err: errors.New("boom")}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
err := pre(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "auto-detect mailbox") {
t.Fatalf("expected wrapped runner error, got %v", err)
}
}
// ── installMailHook composition ────────────────────────────────
func TestInstallMailHook_NoOpForOtherProduct(t *testing.T) {
cmd := newMailSendStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installMailHook(cmd, "chat", "send_email", nil)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !originalCalled {
t.Fatal("original PreRunE should still run")
}
// Setting a bad attachment should NOT fail since hook is no-op for chat.
if err := cmd.Flags().Set("attachment", "/tmp/no_such_path_for_chat.bin"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("non-mail product must not validate attachments: %v", err)
}
}
func TestInstallMailHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newMailSendStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
originalCalled = true
return nil
}
installMailHook(cmd, "mail", "send_email", nil)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallMailHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newMailSendStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installMailHook(cmd, "mail", "send_email", nil)
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallMailHook_AttachesToAllAttachmentTools(t *testing.T) {
tools := []string{
"send_email",
"create_reply_draft",
"create_replyall_draft",
"create_forward_draft",
"create_draft",
"update_draft",
}
for _, tool := range tools {
cmd := newMailSendStub()
installMailHook(cmd, "mail", tool, nil)
if err := cmd.Flags().Set("attachment", "/tmp/no_such_file_for_"+tool); err != nil {
t.Fatal(err)
}
err := cmd.PreRunE(cmd, nil)
if err == nil {
t.Fatalf("tool %s: expected attachment validation to fire", tool)
}
}
}
func TestInstallMailHook_NilCmdSafe(t *testing.T) {
// Defensive: should not panic.
installMailHook(nil, "mail", "send_email", nil)
}
// ── extractFirstMailboxEmail wrap handling ─────────────────────
func TestExtractFirstMailboxEmail_HandlesRuntimeRunnerWrapping(t *testing.T) {
// Mirrors runtimeRunner.executeInvocation: Response is wrapped as
// {"endpoint": "...", "content": {emailAccounts: [...]}}.
wrapped := map[string]any{
"endpoint": "https://mcp.example.com",
"content": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "real@example.com", "type": "PERSONAL"},
},
},
}
if got := extractFirstMailboxEmail(wrapped); got != "real@example.com" {
t.Fatalf("wrapped extraction failed: got %q", got)
}
}
func TestExtractFirstMailboxEmail_HandlesNestedResultUnderContent(t *testing.T) {
wrapped := map[string]any{
"content": map[string]any{
"result": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "nested@example.com"},
},
},
},
}
if got := extractFirstMailboxEmail(wrapped); got != "nested@example.com" {
t.Fatalf("nested extraction failed: got %q", got)
}
}
func TestExtractFirstMailboxEmail_TextBlockFallback(t *testing.T) {
wrapped := map[string]any{
"content": []any{
map[string]any{"type": "text", "text": `{"emailAccounts":[{"email":"text@example.com"}]}`},
},
}
if got := extractFirstMailboxEmail(wrapped); got != "text@example.com" {
t.Fatalf("text-block extraction failed: got %q", got)
}
}
func TestExtractFirstMailboxEmail_ReturnsEmptyForEmptyAccounts(t *testing.T) {
if extractFirstMailboxEmail(map[string]any{"content": map[string]any{"emailAccounts": []any{}}}) != "" {
t.Fatal("expected empty for no accounts")
}
if extractFirstMailboxEmail(nil) != "" {
t.Fatal("expected empty for nil")
}
}
// ── pickMailboxForUserSearch tier preference ───────────────────
func TestPickMailboxForUserSearch_PrefersEnterprise(t *testing.T) {
resp := map[string]any{
"content": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
},
},
}
email, kind := pickMailboxForUserSearch(resp)
if email != "biz@corp.com" {
t.Fatalf("expected enterprise pick, got %q", email)
}
if kind != mailboxKindEnterprise {
t.Fatalf("expected enterprise kind, got %v", kind)
}
}
func TestPickMailboxForUserSearch_FallsBackToPersonalWithKind(t *testing.T) {
resp := map[string]any{
"content": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
},
},
}
email, kind := pickMailboxForUserSearch(resp)
if email != "personal@dingtalk.com" {
t.Fatalf("expected personal email, got %q", email)
}
if kind != mailboxKindPersonal {
t.Fatalf("expected personal kind, got %v", kind)
}
}
func TestPickMailboxForUserSearch_EmptyResponse(t *testing.T) {
email, kind := pickMailboxForUserSearch(nil)
if email != "" || kind != mailboxKindUnknown {
t.Fatalf("expected empty unknown, got email=%q kind=%v", email, kind)
}
}
func TestMailUserSearchEmailFallback_PersonalMailboxEmitsSkipMarker(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{
"content": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
},
},
}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
err := pre(cmd, nil)
if err == nil {
t.Fatal("expected validation error to short-circuit personal mailbox")
}
if !strings.Contains(err.Error(), "PAT_MEDIUM_RISK_NO_PERMISSION") {
t.Fatalf("missing skip marker, got %v", err)
}
// Confirm we did NOT mutate --email when refusing.
if got, _ := cmd.Flags().GetString("email"); got != "" {
t.Fatalf("email should remain unset, got %q", got)
}
}
func TestMailUserSearchEmailFallback_PrefersEnterpriseOverPersonal(t *testing.T) {
runner := &fakeMailboxRunner{resp: map[string]any{
"content": map[string]any{
"emailAccounts": []any{
map[string]any{"email": "p@dingtalk.com", "type": "PERSONAL"},
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
},
},
}}
pre := newMailUserSearchEmailFallback(runner)
cmd := newMailUserSearchStub()
if err := pre(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got, _ := cmd.Flags().GetString("email"); got != "biz@corp.com" {
t.Fatalf("fallback email = %q, want biz@corp.com", got)
}
}
-107
View File
@@ -1,107 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping
// function applied to executor.Result.Response before the formatter runs.
// See discovery-schema-v3 §2.5.
//
// Apply order (deterministic): drop → rename → columns.
// Columns filter only takes effect under --format=table (the formatter
// consults it via the "_columns" marker key this function injects).
// Returns nil when the spec is empty so callers can skip wiring.
func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any {
if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 {
return nil
}
dropped := append([]string(nil), spec.Drop...)
renamed := make(map[string]string, len(spec.Rename))
for k, v := range spec.Rename {
renamed[k] = v
}
columns := append([]string(nil), spec.Columns...)
return func(resp map[string]any) map[string]any {
if resp == nil {
return resp
}
applyDrop(resp, dropped)
applyRename(resp, renamed)
if len(columns) > 0 {
resp["_columns"] = append([]string(nil), columns...)
}
return resp
}
}
// applyDrop removes the named keys at the top level and one level of nested
// object. Missing keys are silently ignored. Keys with "." are treated as a
// two-part path (parent.child).
func applyDrop(m map[string]any, keys []string) {
for _, key := range keys {
if key == "" {
continue
}
delete(m, key)
}
for _, v := range m {
if inner, ok := v.(map[string]any); ok {
for _, key := range keys {
if key == "" {
continue
}
delete(inner, key)
}
}
}
}
// applyRename moves fields from src key to dst key at top level and one level
// of nested object. Collisions overwrite silently. Missing src keys are
// no-ops.
func applyRename(m map[string]any, mapping map[string]string) {
if len(mapping) == 0 {
return
}
// First pass: top level.
for src, dst := range mapping {
if src == "" || dst == "" || src == dst {
continue
}
if v, ok := m[src]; ok {
m[dst] = v
delete(m, src)
}
}
// Second pass: one level nested.
for _, v := range m {
inner, ok := v.(map[string]any)
if !ok {
continue
}
for src, dst := range mapping {
if src == "" || dst == "" || src == dst {
continue
}
if val, ok := inner[src]; ok {
inner[dst] = val
delete(inner, src)
}
}
}
}
-257
View File
@@ -1,257 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
-441
View File
@@ -1,441 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package compat — pipeline executor for CLIToolOverride.Pipeline.
//
// A pipeline turns a single CLI command into an ordered sequence of MCP
// tool calls plus optional HTTP-download sinks, declared entirely in the
// envelope JSON. Use cases:
//
// 1. submit-job + poll-status + download-result patterns (the canonical
// example: `dws sheet export --node X --output PATH` calls
// submit_export_job → query_export_job (poll until status=done) →
// HTTP GET downloadUrl → write to PATH).
// 2. compose-then-update flows where step 2's args reference step 1's
// response.
//
// Templates supported in PipelineStep.Args / DownloadURLField:
//
// $flag.<aliasName> — value of the user's CLI flag whose alias
// equals <aliasName>
// $step.<idx>.<dotPath> — field from a prior step's response
// literal string — passed through unchanged
//
// Limitations (intentional, to keep the executor small):
// - No conditional branching: steps run unconditionally in order.
// - No retry-on-error: the pipeline aborts on the first runner error.
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
// - Download step uses the standard library net/http with no custom
// timeout (relies on the user's Ctrl-C).
package compat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
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/market"
)
// pipelineCtx carries flag values + accumulated step responses through
// the executor. Unexported because callers always interact via runPipeline.
type pipelineCtx struct {
flags map[string]string
stepOutputs []map[string]any
}
// runPipeline executes route.Pipeline against runner, returning the last
// "call"-type step's response (or a synthesized success payload if the
// pipeline ends with a "download" step). The map is the shape returned to
// the user via the standard output formatter.
func runPipeline(
ctx context.Context,
cmd *cobra.Command,
runner executor.Runner,
route Route,
flagValues map[string]string,
) (map[string]any, error) {
pctx := &pipelineCtx{
flags: flagValues,
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
}
var lastCallResponse map[string]any
for i, step := range route.Pipeline {
stepType := strings.TrimSpace(step.Type)
if stepType == "" {
stepType = "call"
}
switch stepType {
case "call":
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
lastCallResponse = resp
case "download":
resp, err := executePipelineDownload(cmd, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
default:
return nil, apperrors.NewValidation(
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
)
}
}
if lastCallResponse != nil {
return lastCallResponse, nil
}
return map[string]any{"success": true}, nil
}
// executePipelineCall resolves args templates, then either polls or fires
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
// non-empty enable polling.
func executePipelineCall(
ctx context.Context,
runner executor.Runner,
route Route,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
if strings.TrimSpace(step.Tool) == "" {
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
}
args, err := resolveArgs(step.Args, pctx)
if err != nil {
return nil, err
}
invoke := func() (map[string]any, error) {
invocation := executor.NewCompatibilityInvocation(
route.Use,
route.Target.CanonicalProduct,
step.Tool,
args,
)
result, err := runner.Run(ctx, invocation)
if err != nil {
return nil, err
}
if result.Response == nil {
return map[string]any{}, nil
}
// Fail-fast on MCP business errors. Pre-execution validation (cobra
// MarkFlagRequired) only checks that the flag was set, not that
// the value is non-empty — so a `--required-flag ""` reaches here
// and the upstream tool rejects with errorCode. Without this check
// the pipeline proceeds to poll/download and either spins until
// PollTimeout or burns through retries.
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
msg := getDotPath(result.Response, "content.errorMessage")
return nil, apperrors.NewValidation(fmt.Sprintf(
"%s rejected: %s — %v", step.Tool, errCode, msg,
))
}
return result.Response, nil
}
if strings.TrimSpace(step.PollUntilField) == "" {
return invoke()
}
// Polling loop.
interval := time.Duration(step.PollIntervalSec) * time.Second
if interval <= 0 {
interval = 2 * time.Second
}
timeoutSec := step.PollTimeoutSec
if timeoutSec <= 0 {
timeoutSec = 300
}
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for {
resp, err := invoke()
if err != nil {
return nil, err
}
actual := getDotPath(resp, step.PollUntilField)
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
return resp, nil
}
if time.Now().After(deadline) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
}
}
// executePipelineDownload resolves the URL template, fetches the body via
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
// Empty output path → print URL to stdout (terminal-friendly mode).
func executePipelineDownload(
cmd *cobra.Command,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
if err != nil {
return nil, err
}
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
if urlStr == "" {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline download: URL template %q resolved to empty value",
step.DownloadURLField,
))
}
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
// Always print machine-parseable "key: value" lines. Tests and shell
// pipelines that consume the pipeline output (regex / awk) rely on
// this exact format. The structured JSON output follows via
// output.WriteCommandPayload, so AI / SDK callers still get a typed
// response.
if jobID != "" {
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
}
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
if outputPath == "" {
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
}, nil
}
// If outputPath is a directory, infer filename from URL basename.
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
filename := inferFilenameFromURL(urlStr)
if filename == "" {
filename = fmt.Sprintf("export_%d", time.Now().Unix())
}
outputPath = filepath.Join(outputPath, filename)
}
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
if err != nil {
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("create %s: %w", outputPath, err)
}
defer out.Close()
written, err := io.Copy(out, resp.Body)
if err != nil {
return nil, fmt.Errorf("write %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
"output": outputPath,
"size": written,
}, nil
}
// resolveArgs applies resolveTemplate to every value in the map.
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
out := make(map[string]any, len(args))
for k, tmpl := range args {
v, err := resolveTemplate(tmpl, pctx)
if err != nil {
return nil, fmt.Errorf("arg %q: %w", k, err)
}
out[k] = v
}
return out, nil
}
// resolveTemplate evaluates a single template string. Returns the literal
// when input does not start with '$'.
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
s := strings.TrimSpace(tmpl)
if !strings.HasPrefix(s, "$") {
return s, nil
}
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
dot := strings.Index(s, ".")
if dot <= 0 || dot == len(s)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
}
head := s[:dot]
tail := s[dot+1:]
switch head {
case "$flag":
// tail is a flag alias name (no nested path supported)
return pctx.flags[tail], nil
case "$step":
// tail format: <idx>.<dotPath>
secondDot := strings.Index(tail, ".")
if secondDot <= 0 || secondDot == len(tail)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
}
idxStr := tail[:secondDot]
dotPath := tail[secondDot+1:]
idx, err := strconv.Atoi(idxStr)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
}
if idx < 0 || idx >= len(pctx.stepOutputs) {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
}
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
default:
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
}
}
// getDotPath walks dotPath through nested map[string]any. Returns nil if
// any segment is missing or the value isn't a map at an intermediate step.
func getDotPath(m map[string]any, dotPath string) any {
parts := strings.Split(dotPath, ".")
var current any = m
for _, p := range parts {
nested, ok := current.(map[string]any)
if !ok {
return nil
}
current = nested[p]
}
return current
}
// inferFilenameFromURL extracts the basename from a URL's path component,
// stripping query string + fragment. Returns "" if the URL doesn't parse
// or has no useful basename.
func inferFilenameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
base := path.Base(u.Path)
if base == "" || base == "/" || base == "." {
return ""
}
return base
}
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
// field at top level or one level under common MCP wrappers ("content" /
// "result"), so the synthetic download response can echo it back to the
// user. Returns "" when no jobId is present anywhere in prior responses.
func inferJobIDFromContext(pctx *pipelineCtx) any {
candidates := []string{"jobId", "content.jobId", "result.jobId"}
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
for _, p := range candidates {
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
return v
}
}
}
return ""
}
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
// the FlagBinding's primary CLI flag name, so the pipeline executor can
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
// always included (they are the whole point of the lookup).
//
// Note on key choice: buildOverrideBindings populates FlagName from the
// envelope's `alias` field (or kebab-case of the MCP property name when
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
// so $flag templates reference the user-visible CLI flag name, e.g.
// "$flag.node" matches `--node`.
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
flags := cmd.Flags()
out := make(map[string]string, len(bindings))
for _, b := range bindings {
primary := strings.TrimSpace(b.FlagName)
if primary == "" {
primary = strings.TrimSpace(b.Alias)
}
if primary == "" {
continue
}
// Try the primary flag name first, then any of the extra aliases.
// Whichever the user actually set wins; if none was set, the
// cobra-level default value is returned.
candidates := make([]string, 0, 2+len(b.Aliases))
candidates = append(candidates, primary)
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
candidates = append(candidates, a)
}
for _, a := range b.Aliases {
if a = strings.TrimSpace(a); a != "" {
candidates = append(candidates, a)
}
}
var value string
for _, c := range candidates {
f := flags.Lookup(c)
if f == nil {
continue
}
value = f.Value.String()
if f.Changed {
break
}
}
out[primary] = value
}
return out
}
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
// the other side as Go primitives where appropriate. Unused for now —
// the resolveTemplate path returns strings as-is to keep the contract
// simple; tools that need JSON-shaped values can use the existing
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
// composition is not in scope for the MVP).
var _ = json.Unmarshal
File diff suppressed because it is too large Load Diff
-135
View File
@@ -1,135 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
// The envelope is remote data; none of these malformed shapes may panic the
// command build — pflag panics on duplicate long names, duplicate shorthands,
// and multi-character shorthands, and a poisoned discovery cache used to take
// down every CLI invocation this way (pre-1.0.32 lockout class).
func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
tests := []struct {
name string
flags map[string]market.CLIFlagOverride
}{
{
name: "duplicate shorthand across two flags",
flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "x"},
"beta": {Shorthand: "x"},
},
},
{
name: "multi-character shorthand",
flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "xy"},
},
},
{
name: "primary collides with reserved payload flag",
flags: map[string]market.CLIFlagOverride{
"params": {},
"json": {},
},
},
{
name: "cross-binding duplicate primary via alias",
flags: map[string]market.CLIFlagOverride{
"user_id": {Alias: "target"},
"member_id": {Alias: "target"},
},
},
{
name: "cross-binding alias collides with another primary",
flags: map[string]market.CLIFlagOverride{
"alpha": {},
"beta": {Aliases: []string{"alpha"}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-guard",
CLI: market.CLIOverlay{
ID: "guard",
Command: "guard",
ToolOverrides: map[string]market.CLIToolOverride{
"guard_tool": {
CLIName: "boom",
Flags: tc.flags,
},
},
},
},
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
cmds[0].SetArgs([]string{"boom", "--help"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
if err := cmds[0].Execute(); err != nil {
t.Fatalf("execute --help: %v", err)
}
})
}
}
// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
// flags claim the same shorthand, the first (sorted param order) keeps it
// and the second still registers its long flag.
func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-guard",
CLI: market.CLIOverlay{
ID: "guard",
Command: "guard",
ToolOverrides: map[string]market.CLIToolOverride{
"guard_tool": {
CLIName: "boom",
Flags: map[string]market.CLIFlagOverride{
"alpha": {Shorthand: "x"},
"beta": {Shorthand: "x"},
},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
}
short := boom.Flags().ShorthandLookup("x")
if short == nil || short.Name != "alpha" {
t.Fatalf("shorthand -x bound to %v, want alpha", short)
}
if boom.Flags().Lookup("beta") == nil {
t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
}
}
-314
View File
@@ -1,314 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) {
t.Parallel()
// envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
bindings := []FlagBinding{
{
FlagName: "query",
Aliases: []string{"keyword"},
Property: "keyword",
Kind: ValueString,
Usage: "搜索关键词",
Required: true,
Positional: true,
PositionalIndex: 0,
},
}
cmd := &cobra.Command{Use: "search"}
ApplyBindings(cmd, bindings)
primary := cmd.Flags().Lookup("query")
if primary == nil {
t.Fatal("--query flag should be registered for dual-mode positional")
}
if primary.Hidden {
t.Fatal("--query flag should be visible")
}
hidden := cmd.Flags().Lookup("keyword")
if hidden == nil {
t.Fatal("--keyword alias flag should be registered")
}
if !hidden.Hidden {
t.Fatal("--keyword alias flag should be hidden")
}
// --query should NOT be marked required at cobra level — that would
// break flag-only invocation when arity is relaxed.
if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok {
t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)")
}
}
func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) {
t.Parallel()
// Pure positional (no Alias / no Aliases) → no flag should be registered;
// arity validator (set in NewDirectCommand) handles required-presence.
bindings := []FlagBinding{
{
Property: "text",
Kind: ValueString,
Required: true,
Positional: true,
PositionalIndex: 0,
},
}
cmd := &cobra.Command{Use: "send"}
ApplyBindings(cmd, bindings)
if f := cmd.Flags().Lookup("text"); f != nil {
t.Fatalf("pure positional should not register a flag, got %+v", f)
}
}
func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "dept-ids", Alias: "deptIds", Property: "deptIds", Kind: ValueFloatSlice},
{FlagName: "ratio", Property: "ratio", Kind: ValueFloat},
{FlagName: "enabled-flags", Property: "enabledFlags", Kind: ValueBoolSlice},
{FlagName: "base-id", Alias: "baseId", Property: "baseId", Kind: ValueString},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
if err := cmd.Flags().Set("deptIds", "1,2.5"); err != nil {
t.Fatalf("Set(deptIds) error = %v", err)
}
if err := cmd.Flags().Set("ratio", "1.25"); err != nil {
t.Fatalf("Set(ratio) error = %v", err)
}
if err := cmd.Flags().Set("enabled-flags", "true,false"); err != nil {
t.Fatalf("Set(enabled-flags) error = %v", err)
}
if err := cmd.Flags().Set("baseId", "B1"); err != nil {
t.Fatalf("Set(baseId) error = %v", err)
}
params, err := CollectBindings(cmd, bindings, nil)
if err != nil {
t.Fatalf("CollectBindings() error = %v", err)
}
if params["baseId"] != "B1" {
t.Fatalf("baseId = %#v, want B1", params["baseId"])
}
if params["ratio"] != 1.25 {
t.Fatalf("ratio = %#v, want 1.25", params["ratio"])
}
if want := []any{1.0, 2.5}; !reflect.DeepEqual(params["deptIds"], want) {
t.Fatalf("deptIds = %#v, want %#v", params["deptIds"], want)
}
if want := []any{true, false}; !reflect.DeepEqual(params["enabledFlags"], want) {
t.Fatalf("enabledFlags = %#v, want %#v", params["enabledFlags"], want)
}
aliasFlag := cmd.Flags().Lookup("baseId")
if aliasFlag == nil || !aliasFlag.Hidden {
t.Fatalf("baseId alias flag hidden = false, want true")
}
}
func TestHiddenAliasFlagsDoNotInflateLeafCount(t *testing.T) {
t.Parallel()
// overlay leaf: 2 primary + 2 hidden aliases = 4 total, but only 2 visible
overlay := &cobra.Command{Use: "list"}
ApplyBindings(overlay, []FlagBinding{
{FlagName: "start-time", Alias: "startTime", Property: "startTime", Kind: ValueString},
{FlagName: "end-time", Alias: "endTime", Property: "endTime", Kind: ValueString},
})
// curated compat leaf: 3 primary, 0 aliases = 3 visible
curated := &cobra.Command{Use: "list"}
ApplyBindings(curated, []FlagBinding{
{FlagName: "start", Property: "start", Kind: ValueString},
{FlagName: "end", Property: "end", Kind: ValueString},
{FlagName: "calendar-id", Property: "calendarId", Kind: ValueString},
})
overlayCount := cobracmd.LocalFlagCount(overlay)
curatedCount := cobracmd.LocalFlagCount(curated)
// overlay has 2 visible flags (start-time, end-time) + json + params = 4
// curated has 3 visible flags (start, end, calendar-id) + json + params = 5
if overlayCount >= curatedCount {
t.Fatalf("overlay visible flags (%d) >= curated visible flags (%d); hidden aliases should not be counted",
overlayCount, curatedCount)
}
// shouldReplaceCompatLeaf should NOT replace curated with overlay
if cobracmd.ShouldReplaceLeaf(curated, overlay) {
t.Fatal("cobracmd.ShouldReplaceLeaf(curated, overlay) = true; overlay should not displace curated")
}
}
func TestCollectBindingsParsesJSONFlagValue(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "fields", Property: "fields", Kind: ValueJSON},
{FlagName: "config", Property: "config", Kind: ValueJSON},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
if err := cmd.Flags().Set("fields", `[{"fieldName":"title","type":"text"}]`); err != nil {
t.Fatalf("Set(fields) error = %v", err)
}
if err := cmd.Flags().Set("config", `{"options":[{"name":"high"}]}`); err != nil {
t.Fatalf("Set(config) error = %v", err)
}
params, err := CollectBindings(cmd, bindings, nil)
if err != nil {
t.Fatalf("CollectBindings() error = %v", err)
}
fields, ok := params["fields"].([]any)
if !ok || len(fields) != 1 {
t.Fatalf("fields = %#v, want array of 1 element", params["fields"])
}
firstField, ok := fields[0].(map[string]any)
if !ok || firstField["fieldName"] != "title" {
t.Fatalf("fields[0] = %#v, want {fieldName:title, type:text}", fields[0])
}
config, ok := params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want map", params["config"])
}
options, ok := config["options"].([]any)
if !ok || len(options) != 1 {
t.Fatalf("config.options = %#v, want array of 1", config["options"])
}
}
func TestCollectSchemaFlagsPicksUpUnboundFlags(t *testing.T) {
t.Parallel()
// Simulate a plugin command with schema-generated flags but no bindings.
cmd := &cobra.Command{Use: "greet"}
cmd.Flags().String("name", "", "Name of person")
cmd.Flags().String("language", "en", "Language")
cmd.Flags().Int("count", 0, "Repeat count")
cmd.Flags().Bool("loud", false, "Loud mode")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
// User sets --name and --count but not --language
_ = cmd.Flags().Set("name", "Alice")
_ = cmd.Flags().Set("count", "3")
_ = cmd.Flags().Set("loud", "true")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
if params["name"] != "Alice" {
t.Errorf("name = %v, want Alice", params["name"])
}
if params["count"] != 3 {
t.Errorf("count = %v, want 3", params["count"])
}
if params["loud"] != true {
t.Errorf("loud = %v, want true", params["loud"])
}
// language was not set by user, should not appear
if _, exists := params["language"]; exists {
t.Errorf("language should not be in params (not set by user)")
}
// json/params are reserved, should not appear
if _, exists := params["json"]; exists {
t.Error("json should be skipped")
}
}
func TestCollectSchemaFlagsSkipsBoundFlags(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "dept-id", Property: "deptId", Kind: ValueString},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
// Also add a schema-generated flag
cmd.Flags().String("title", "", "Title")
_ = cmd.Flags().Set("dept-id", "D001")
_ = cmd.Flags().Set("title", "Hello")
params := make(map[string]any)
collectSchemaFlags(cmd, bindings, params)
// dept-id is bound, should NOT be collected by collectSchemaFlags
if _, exists := params["dept_id"]; exists {
t.Error("dept-id should be skipped (already has binding)")
}
// title is unbound, should be collected
if params["title"] != "Hello" {
t.Errorf("title = %v, want Hello", params["title"])
}
}
func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("name", "", "Name")
cmd.Flags().Bool("debug", false, "Debug")
cmd.Flags().Bool("verbose", false, "Verbose")
cmd.Flags().Bool("dry-run", false, "Dry run")
cmd.Flags().String("format", "json", "Format")
cmd.Flags().String("profile", "", "Profile")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
_ = cmd.Flags().Set("name", "Bob")
_ = cmd.Flags().Set("debug", "true")
_ = cmd.Flags().Set("verbose", "true")
_ = cmd.Flags().Set("dry-run", "true")
_ = cmd.Flags().Set("format", "table")
_ = cmd.Flags().Set("profile", "corp_profile")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
if params["name"] != "Bob" {
t.Errorf("name = %v, want Bob", params["name"])
}
// Global flags should be skipped
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
if _, exists := params[skip]; exists {
t.Errorf("%s should be skipped (global flag)", skip)
}
}
}
-186
View File
@@ -1,186 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
-198
View File
@@ -1,198 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
-114
View File
@@ -1,114 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// todo_hooks.go — CLI-side validators for the `todo` product. The envelope
// describes PersonalTodoCreateVO.parentId as a plain string flag (`--parent-id`)
// and the upstream MCP tool create_personal_sub_todo silently accepts any
// non-empty value: when a non-numeric string slips through, the server treats
// the missing numeric parent as "no parent" and creates an *orphan* root-level
// todo instead of failing. The auto-test
// todo/test_03_todo_create_sub.py::test_create_sub_todo_invalid_parent_id
// expects the CLI to reject the invalid value before it ever reaches MCP.
//
// The wukong reference implementation already does the same check inside its
// hand-written cobra RunE (see dws-wukong/wukong/products/todo.go ~line 90:
// strconv.ParseInt + CLIError with "父待办 ID 必须是纯数字, 当前值: ..."). The
// open-source CLI is envelope-driven, so we attach the equivalent guard as a
// PreRunE hook here. Empty parent-id is intentionally NOT validated here —
// envelope already marks it required, so cobra's MarkFlagRequired handles the
// missing case with the standard "required flag(s) ... not set" message.
package compat
import (
"fmt"
"strconv"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// todoToolsWithNumericParentId lists every todo toolName whose --parent-id
// must be coerced to a pure-numeric long. Today only create_personal_sub_todo
// needs this; if future tools (e.g. add_sub_todo) join, append here.
var todoToolsWithNumericParentId = map[string]bool{
"create_personal_sub_todo": true,
}
// installTodoHook wires todo-specific PreRunE validators onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-todo products and
// for todo tools that do not need extra client-side checks.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installTodoHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "todo" {
return
}
if !todoToolsWithNumericParentId[toolName] {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validateTodoParentIdNumeric(c)
}
}
// validateTodoParentIdNumeric inspects --parent-id; if non-empty it must
// parse as int64. Empty values are passed through so cobra's MarkFlagRequired
// (driven by the envelope's `"required": true`) still owns the missing-flag
// error message, matching the existing UX for other required flags.
//
// Error wording mirrors wukong (dws-wukong/wukong/products/todo.go ~L97) so
// agents and humans see a stable message across both editions. The
// apperrors.NewValidation wrapper guarantees stderr renders as
// "Error: [VALIDATION] ..." (PrintHumanAt) or `{"error":{...}}` (PrintJSON),
// both of which satisfy the auto-test substring assertion
// `"error" in result.stderr.lower()`.
func validateTodoParentIdNumeric(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
flag := cmd.Flags().Lookup("parent-id")
if flag == nil {
return nil
}
raw, err := cmd.Flags().GetString("parent-id")
if err != nil {
// Flag exists but type is not string — defensive no-op, do not block.
return nil
}
v := strings.TrimSpace(raw)
if v == "" {
return nil
}
if _, parseErr := strconv.ParseInt(v, 10, 64); parseErr != nil {
return apperrors.NewValidation(
fmt.Sprintf("父待办 ID 必须是纯数字, 当前值: %s", v),
apperrors.WithReason("invalid_parent_id"),
apperrors.WithHint("请通过 'dws todo task list' 获取正确的父待办任务 ID。"),
apperrors.WithOperation("todo.task.create-sub.parent-id"),
)
}
return nil
}
-217
View File
@@ -1,217 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newTodoCreateSubStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `todo task create-sub` (envelope:
// create_personal_sub_todo). Only the flags the hook touches are
// registered; the others are irrelevant to the validation.
func newTodoCreateSubStub() *cobra.Command {
cmd := &cobra.Command{Use: "create-sub"}
cmd.Flags().String("parent-id", "", "parent todo id")
cmd.Flags().String("title", "", "title")
cmd.Flags().String("executors", "", "executors")
return cmd
}
func TestValidateTodoParentIdNumeric_AcceptsPureDigits(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "53340859882"); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil for numeric parent-id, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_RejectsAlphanumeric(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "INVALID_PARENT_ID_99999"); err != nil {
t.Fatalf("set flag: %v", err)
}
err := validateTodoParentIdNumeric(cmd)
if err == nil {
t.Fatal("expected validation error for non-numeric parent-id")
}
if !strings.Contains(err.Error(), "纯数字") {
t.Fatalf("expected '纯数字' in error, got %v", err)
}
if !strings.Contains(err.Error(), "INVALID_PARENT_ID_99999") {
t.Fatalf("expected offending value in error, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_RejectsLeadingZeroPaddedHex(t *testing.T) {
// "0xdeadbeef" should fail strconv.ParseInt base 10, ensuring we are
// not silently accepting hex-shaped IDs.
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "0xdeadbeef"); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err == nil {
t.Fatal("expected validation error for hex-shaped parent-id")
}
}
func TestValidateTodoParentIdNumeric_RejectsWhitespacePadded(t *testing.T) {
// Trimmed value is "abc" — must still reject; equally guards against
// " 123 " false-positive once trimmed (which we DO accept as 123).
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", " abc "); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err == nil {
t.Fatal("expected validation error for non-numeric (whitespace-padded) parent-id")
}
}
func TestValidateTodoParentIdNumeric_AcceptsWhitespacePaddedDigits(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", " 53340859882 "); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected whitespace-padded digits to pass after trim, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_EmptyPassesThrough(t *testing.T) {
// Envelope marks parent-id required, so cobra produces the missing-flag
// error itself. We must not preempt that with a confusing message.
cmd := newTodoCreateSubStub()
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil for empty parent-id (cobra owns required-check), got %v", err)
}
}
func TestValidateTodoParentIdNumeric_NoFlagRegistered(t *testing.T) {
// Defensive: a command without the flag must not panic / error.
cmd := &cobra.Command{Use: "noop"}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil when --parent-id absent, got %v", err)
}
}
// ── installTodoHook composition ────────────────────────────────
func TestInstallTodoHook_NoOpForOtherProduct(t *testing.T) {
cmd := newTodoCreateSubStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installTodoHook(cmd, "chat", "create_personal_sub_todo")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE should still run when hook skips")
}
// Bad parent-id must NOT fail since hook is no-op for non-todo product.
if err := cmd.Flags().Set("parent-id", "INVALID"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("non-todo product must not validate parent-id: %v", err)
}
}
func TestInstallTodoHook_NoOpForOtherTodoTool(t *testing.T) {
// e.g. `todo task get` reuses parent-id-less plumbing — make sure we do
// not blanket-validate every todo leaf.
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "get_personal_todo_detail")
if err := cmd.Flags().Set("parent-id", "INVALID"); err != nil {
t.Fatal(err)
}
if cmd.PreRunE != nil {
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("non-target todo tool must not validate parent-id: %v", err)
}
}
}
func TestInstallTodoHook_TargetToolRejectsInvalid(t *testing.T) {
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if cmd.PreRunE == nil {
t.Fatal("installTodoHook should install a PreRunE for the target tool")
}
if err := cmd.Flags().Set("parent-id", "INVALID_PARENT_ID_99999"); err != nil {
t.Fatal(err)
}
err := cmd.PreRunE(cmd, nil)
if err == nil {
t.Fatal("expected hook to reject non-numeric parent-id")
}
if !strings.Contains(err.Error(), "纯数字") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestInstallTodoHook_TargetToolAcceptsValid(t *testing.T) {
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if err := cmd.Flags().Set("parent-id", "53340859882"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("numeric parent-id must pass: %v", err)
}
}
func TestInstallTodoHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newTodoCreateSubStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
originalCalled = true
return nil
}
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if err := cmd.Flags().Set("parent-id", "1"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallTodoHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newTodoCreateSubStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installTodoHook(cmd, "todo", "create_personal_sub_todo")
// Even with a VALID parent-id, the chained original error must bubble up
// before our validation runs.
if err := cmd.Flags().Set("parent-id", "1"); err != nil {
t.Fatal(err)
}
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallTodoHook_NilCmdSafe(t *testing.T) {
// Defensive: should not panic.
installTodoHook(nil, "todo", "create_personal_sub_todo")
}
-457
View File
@@ -1,457 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"gopkg.in/yaml.v3"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
return value, nil
case "iso8601_to_millis":
return transformISO8601ToMillis(value)
case "csv_to_array":
return transformCSVToArray(value)
case "json_parse":
return transformJSONParse(value)
case "json_parse_strict":
return transformJSONParseStrict(value)
case "enum_map":
return transformEnumMap(value, args)
case "file_read":
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
case "parse_bool":
return transformParseBool(value)
case "attendance_class_check_time":
return transformAttendanceClassCheckTime(value)
case "string_to_int64":
return transformStringToInt64(value)
default:
return value, nil
}
}
// transformInvertBool flips a boolean: true → false, false → true. Strings
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
// `transform: invert_bool` and the framework flips at send time.
func transformInvertBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return !v, nil
case string:
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "true", "1", "yes", "on":
return false, nil
case "false", "0", "no", "off", "":
return true, nil
}
return value, nil
default:
return value, nil
}
}
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
// zero value). Used by envelope flags that are semantically boolean but must be
// declared as string flags to accept an explicit `false` on the command line
// (cobra bool flags drop the space-form value). Unknown tokens pass through
// unchanged so upstream validators own the error wording.
func transformParseBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
}
return value, nil
default:
return value, nil
}
}
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
// every "HH:mm" checkTime under sections[*].times[*] and
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
// numeric form; the envelope cannot express this nested walk, so it lives here.
func transformAttendanceClassCheckTime(value any) (any, error) {
parsed, err := transformJSONParseStrict(value)
if err != nil {
return nil, err
}
classVO, ok := parsed.(map[string]any)
if !ok {
return parsed, nil
}
cst := time.FixedZone("CST", 8*3600)
convertOne := func(obj map[string]any) {
if ct, ok := obj["checkTime"].(string); ok {
ct = strings.TrimSpace(ct)
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
obj["checkTime"] = float64(t.UnixMilli())
}
}
}
if sections, ok := classVO["sections"].([]any); ok {
for _, sec := range sections {
if secMap, ok := sec.(map[string]any); ok {
if times, ok := secMap["times"].([]any); ok {
for _, t := range times {
if tMap, ok := t.(map[string]any); ok {
convertOne(tMap)
}
}
}
}
}
}
if setting, ok := classVO["setting"].(map[string]any); ok {
if restList, ok := setting["topRestTimeList"].([]any); ok {
for _, item := range restList {
if itemMap, ok := item.(map[string]any); ok {
convertOne(itemMap)
}
}
}
}
return classVO, nil
}
func transformISO8601ToMillis(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// Try direct millisecond integer first.
if millis, err := strconv.ParseInt(s, 10, 64); err == nil && millis > 1_000_000_000_000 {
return millis, nil
}
layouts := []struct {
layout string
location *time.Location
}{
{layout: time.RFC3339},
{layout: "2006-01-02T15:04:05"},
{layout: "2006-01-02 15:04:05"},
{layout: "2006-01-02", location: time.UTC},
}
for _, candidate := range layouts {
var (
parsed time.Time
err error
)
if candidate.location != nil {
parsed, err = time.ParseInLocation(candidate.layout, s, candidate.location)
} else {
parsed, err = time.Parse(candidate.layout, s)
}
if err == nil {
return parsed.UnixMilli(), nil
}
}
return nil, apperrors.NewValidation(fmt.Sprintf("iso8601_to_millis: cannot parse %q as ISO-8601", s))
}
func transformCSVToArray(value any) (any, error) {
s, ok := toString(value)
if !ok {
// If it's already a slice, pass through.
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return []any{}, nil
}
// If already looks like a JSON array, try parsing it.
if strings.HasPrefix(s, "[") {
var arr []any
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return arr, nil
}
}
parts := strings.Split(s, ",")
result := make([]any, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result, nil
}
// transformJSONParse parses a CLI string into a structured value so callers can
// pass complex payloads (JSON arrays/objects) through a single flag.
//
// Two input dialects are accepted, in order:
// 1. Strict JSON — `[{"fieldName":"x","type":"text"}]`
// 2. YAML (flow) — `[{fieldName: x, type: text}]`
//
// YAML is a superset of JSON that permits unquoted keys and strings, which
// dramatically reduces the need for shell-level escaping. Users can therefore
// write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes
// around every token. The output shape is the same either way; downstream
// consumers see the parsed Go value, not the original dialect.
func transformJSONParse(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
// YAML (flow) fallback — accepts `{key: value}` without surrounding
// quotes, which is the natural form when typing at a shell prompt.
if err := yaml.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
// structured array/object value and "garbage in → empty out" is unacceptable.
func transformJSONParseStrict(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
}
func transformEnumMap(value any, args map[string]any) (any, error) {
s, ok := toString(value)
if !ok {
s = fmt.Sprint(value)
}
s = strings.TrimSpace(s)
if mapped, exists := args[s]; exists {
return mapped, nil
}
if defaultVal, exists := args["_default"]; exists {
return defaultVal, nil
}
return value, nil
}
// transformFileRead reads the file at the given path and returns its contents
// as a UTF-8 string. The special path "-" reads from stdin.
//
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
// flag (--content "# 标题") feed the same parameter without conflict.
//
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
// (user input) rather than the generic exit code 1 (transient failure).
func transformFileRead(value any) (any, error) {
s, ok := toString(value)
if !ok {
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
}
s = strings.TrimSpace(s)
if s == "" {
return nil, apperrors.NewValidation("file_read: empty path")
}
var buf []byte
var err error
if s == "-" {
buf, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
}
} else {
buf, err = os.ReadFile(s)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
}
}
if !utf8.Valid(buf) {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
}
return string(buf), nil
}
// transformStringToInt64 parses a string-form integer (e.g. "12345") into an
// int64 so the MCP body carries a numeric value rather than a quoted string.
// Used for envelope flags whose upstream schema requires int64 (e.g. deptId).
//
// Two ergonomic guards are layered on top of the raw parse:
//
// 1. Placeholder rejection — common LLM/AI-agent placeholders for "myself" /
// "root department" (self / me / 我 / root / 0) are NOT valid deptIds. The
// dingtalk root department's deptId is the literal integer 1; if we let
// "self" fall through to the MCP, the server returns an empty result with
// success=true, masking the mistake. Instead, return a validation error
// pointing the caller at the correct usage. Mirrors wukong's cmdutil error
// wording ("根部门 deptId=1,请使用 --id 1") so CLI and wukong agree.
//
// 2. Non-numeric rejection — anything else that fails strconv.ParseInt is
// reported as a validation error rather than silently sent as a string,
// which the upstream server would also reject (or worse: coerce to 0).
//
// Numeric int / int64 inputs pass through unchanged; the transform is a no-op
// when the schema-typed flag already produced an integer.
func transformStringToInt64(value any) (any, error) {
switch v := value.(type) {
case nil:
return value, nil
case int:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
case float64:
// JSON numbers decode as float64; accept only when integer-valued.
if v == float64(int64(v)) {
return int64(v), nil
}
return nil, apperrors.NewValidation(fmt.Sprintf("string_to_int64: %v is not an integer", v))
}
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// Placeholder guard: LLMs often invent symbolic values like "self" / "me" /
// "root" / "我" for "the current user's root department". Catch them with a
// clear error pointing at the canonical deptId=1, instead of forwarding the
// bogus value and letting the upstream return success=true with empty data.
lowered := strings.ToLower(s)
switch lowered {
case "self", "me", "我", "root", "0":
return nil, apperrors.NewValidation(
"flag --id 必须是整数;钉钉根部门 deptId=1,请使用 --id 1",
)
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("flag --id 必须是整数,got %q", s))
}
return n, nil
}
func toString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case fmt.Stringer:
return val.String(), true
default:
return "", false
}
}
-427
View File
@@ -1,427 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestJSONParse_StrictJSON covers the primary path: callers passing
// canonical JSON (as generated programmatically or by agents).
func TestJSONParse_StrictJSON(t *testing.T) {
t.Parallel()
input := `[{"fieldName":"title","type":"text"},{"fieldName":"count","type":"number"}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("strict JSON should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 2 {
t.Fatalf("expected []any of length 2, got %T %v", got, got)
}
}
// TestJSONParse_YAMLFlowFallback is the motivating case: a user types an
// ad-hoc JSON-shaped array without quoting every key and value. YAML flow
// syntax accepts it and the parsed output is indistinguishable from the
// strict-JSON equivalent.
func TestJSONParse_YAMLFlowFallback(t *testing.T) {
t.Parallel()
// Intentionally unquoted keys, unquoted string values, and Chinese
// identifiers — typical of what humans type at a shell.
input := `[{fieldName: 标题, type: text}, {fieldName: 数量, type: number, config: {formatter: INT}}, {fieldName: 状态, type: singleSelect, config: {options: [{name: 待办}, {name: 进行中}, {name: 已完成}]}}, {fieldName: 已确认, type: checkbox}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("YAML-flow input should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok {
t.Fatalf("expected []any, got %T", got)
}
if len(arr) != 4 {
t.Fatalf("expected 4 field definitions, got %d", len(arr))
}
// Spot-check the third entry, which is the most deeply nested.
third, ok := arr[2].(map[string]any)
if !ok {
t.Fatalf("arr[2] expected map[string]any, got %T", arr[2])
}
if third["fieldName"] != "状态" {
t.Errorf("arr[2].fieldName: want 状态, got %v", third["fieldName"])
}
config, ok := third["config"].(map[string]any)
if !ok {
t.Fatalf("arr[2].config expected map, got %T", third["config"])
}
options, ok := config["options"].([]any)
if !ok || len(options) != 3 {
t.Fatalf("arr[2].config.options: want 3 items, got %v", config["options"])
}
}
// TestJSONParse_EmptyString preserves the legacy behaviour of returning the
// original value untouched when the caller passes an empty / whitespace-only
// string, matching how other transforms treat empty input.
func TestJSONParse_EmptyString(t *testing.T) {
t.Parallel()
cases := []string{"", " ", "\n\t"}
for _, in := range cases {
got, err := ApplyTransform(in, "json_parse", nil)
if err != nil {
t.Errorf("empty input %q should not error: %v", in, err)
continue
}
if !reflect.DeepEqual(got, in) {
t.Errorf("empty input %q should pass through, got %v", in, got)
}
}
}
// TestJSONParse_NonString passes through non-string inputs (already-parsed
// values flowing through the pipeline).
func TestJSONParse_NonString(t *testing.T) {
t.Parallel()
preParsed := []any{map[string]any{"k": "v"}}
got, err := ApplyTransform(preParsed, "json_parse", nil)
if err != nil {
t.Fatalf("non-string should pass through: %v", err)
}
if !reflect.DeepEqual(got, preParsed) {
t.Errorf("non-string should pass through unchanged, got %v", got)
}
}
// TestJSONParse_InvalidInput verifies that genuine garbage is still rejected
// with a user-facing validation error that nudges towards `@file` syntax.
func TestJSONParse_InvalidInput(t *testing.T) {
t.Parallel()
// Unterminated bracket — neither valid JSON nor valid YAML flow.
_, err := ApplyTransform("[{fieldName:", "json_parse", nil)
if err == nil {
t.Fatal("expected error for malformed input")
}
if msg := err.Error(); msg == "" {
t.Fatal("error message should be non-empty")
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
// file contents in place of the path.
func TestFileRead_BasicFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
contents := "# Heading\n\n- bullet one\n- bullet two\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
got, err := ApplyTransform(path, "file_read", nil)
if err != nil {
t.Fatalf("file_read should succeed, got err: %v", err)
}
if got != contents {
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
}
}
// TestFileRead_EmptyPath rejects empty input with a validation error rather
// than silently reading "" / cwd. The dispatcher maps validation errors to
// exit code 2 so the user sees a usage problem.
func TestFileRead_EmptyPath(t *testing.T) {
t.Parallel()
_, err := ApplyTransform("", "file_read", nil)
if err == nil {
t.Fatal("expected validation error for empty path")
}
if !strings.Contains(err.Error(), "file_read") {
t.Errorf("error should mention the transform name, got %q", err.Error())
}
}
// TestFileRead_MissingFile surfaces a clear validation error when the path
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
// sees what they passed.
func TestFileRead_MissingFile(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
_, err := ApplyTransform(missing, "file_read", nil)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "definitely-not-here.md") {
t.Errorf("error should mention the missing path, got %q", err.Error())
}
}
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
// content and silently shipping a corrupted byte string would mask a real
// user error.
func TestFileRead_InvalidUTF8(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "binary.dat")
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
_, err := ApplyTransform(path, "file_read", nil)
if err == nil {
t.Fatal("expected UTF-8 validation error for binary input")
}
if !strings.Contains(err.Error(), "UTF-8") {
t.Errorf("error should mention UTF-8, got %q", err.Error())
}
}
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
// silently no-op.
func TestFileRead_NonString(t *testing.T) {
t.Parallel()
_, err := ApplyTransform(123, "file_read", nil)
if err == nil {
t.Fatal("expected validation error for non-string value")
}
}
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
// "-" is reserved for stdin. We don't test stdin redirection here (that
// requires plumbing os.Stdin replacement which complicates the test) — this
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
// in the current directory. The end-to-end stdin path is covered in
// test/cli_compat once the envelope ships.
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
t.Parallel()
// Run with stdin redirected from an empty pipe so we don't hang.
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("setup: %v", err)
}
defer r.Close()
if _, err := w.Write([]byte("piped content")); err != nil {
t.Fatalf("setup: %v", err)
}
w.Close()
origStdin := os.Stdin
os.Stdin = r
defer func() { os.Stdin = origStdin }()
got, err := ApplyTransform("-", "file_read", nil)
if err != nil {
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
}
if got != "piped content" {
t.Errorf("expected stdin contents, got %q", got)
}
}
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
// is gated by name and doesn't regress when the transform name is missing.
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("./some-path", "", nil)
if err != nil {
t.Fatalf("empty transform should pass through, got err: %v", err)
}
if !reflect.DeepEqual(got, "./some-path") {
t.Errorf("expected pass-through, got %v", got)
}
}
func TestInvertBoolTransform(t *testing.T) {
cases := []struct {
in any
want any
}{
{true, false},
{false, true},
{"true", false},
{"false", true},
{"True", false},
{"FALSE", true},
{"on", false},
{"off", true},
{"", true},
}
for _, c := range cases {
got, err := ApplyTransform(c.in, "invert_bool", nil)
if err != nil {
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
}
}
}
// TestStringToInt64_NumericString covers the happy path: callers pass an
// integer-shaped string (the common CLI case where every flag arrives as text)
// and the transform promotes it to int64 so the MCP body carries a number.
func TestStringToInt64_NumericString(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("12345", "string_to_int64", nil)
if err != nil {
t.Fatalf("expected numeric string to parse, got err: %v", err)
}
if got != int64(12345) {
t.Fatalf("expected int64(12345), got %T %v", got, got)
}
}
// TestStringToInt64_NumericPassthrough covers the case where an upstream
// schema-typed flag already produced an integer (e.g. via pflag.Int64) — the
// transform should be a no-op and not double-convert.
func TestStringToInt64_NumericPassthrough(t *testing.T) {
t.Parallel()
cases := []any{int(7), int32(7), int64(7), float64(7)}
for _, in := range cases {
got, err := ApplyTransform(in, "string_to_int64", nil)
if err != nil {
t.Errorf("expected pass-through for %T(%v), got err: %v", in, in, err)
continue
}
if got != int64(7) {
t.Errorf("expected int64(7), got %T %v (input %T)", got, got, in)
}
}
}
// TestStringToInt64_PlaceholderRejected guards the wukong-aligned error wording
// for LLM/AI-agent placeholders. Each of these values must surface a
// validation error pointing at the canonical root deptId=1; if they fell
// through silently the MCP server would return success=true with empty data
// and the caller would never learn they sent garbage.
func TestStringToInt64_PlaceholderRejected(t *testing.T) {
t.Parallel()
placeholders := []string{"self", "me", "我", "root", "0", "SELF", "Me"}
for _, p := range placeholders {
_, err := ApplyTransform(p, "string_to_int64", nil)
if err == nil {
t.Errorf("placeholder %q should reject, got nil error", p)
continue
}
msg := err.Error()
if !strings.Contains(msg, "根部门") || !strings.Contains(msg, "deptId=1") {
t.Errorf("placeholder %q error should mention 根部门/deptId=1, got %q", p, msg)
}
}
}
// TestStringToInt64_NonNumericRejected ensures non-integer strings are
// surfaced as validation errors (exit code 2) rather than forwarded to the
// MCP as a quoted string, which the upstream would reject anyway.
func TestStringToInt64_NonNumericRejected(t *testing.T) {
t.Parallel()
_, err := ApplyTransform("abc", "string_to_int64", nil)
if err == nil {
t.Fatalf("non-numeric input should reject, got nil error")
}
if !strings.Contains(err.Error(), "必须是整数") {
t.Errorf("expected `必须是整数` in error, got %q", err.Error())
}
}
// TestStringToInt64_EmptyPassthrough mirrors the other transforms' contract:
// empty input is a no-op so optional flags that weren't provided don't trip
// the placeholder/format guards.
func TestStringToInt64_EmptyPassthrough(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("", "string_to_int64", nil)
if err != nil {
t.Fatalf("empty string should pass through, got err: %v", err)
}
if got != "" {
t.Errorf("expected empty string pass-through, got %v", got)
}
}
-171
View File
@@ -1,171 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
package compat
import (
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map.
func TestKindFromTypeName(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want ValueKind
}{
{"", ValueString},
{"string", ValueString},
{"STRING", ValueString},
{"int", ValueInt},
{"integer", ValueInt},
{"number", ValueInt},
{"bool", ValueBool},
{"boolean", ValueBool},
{"stringSlice", ValueStringSlice},
{"string_slice", ValueStringSlice},
{"[]string", ValueStringSlice},
{"weird-unknown-type", ValueString},
}
for _, tc := range cases {
if got := kindFromTypeName(tc.in); got != tc.want {
t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
// TestApplyOmitWhen verifies all three modes plus zero-detection.
func TestApplyOmitWhen(t *testing.T) {
t.Parallel()
t.Run("mode_zero_drops_zero_values", func(t *testing.T) {
params := map[string]any{
"a": 0,
"b": "",
"c": false,
"d": []any{},
"e": map[string]any{},
"f": "non-empty",
"g": 42,
}
for k := range params {
applyOmitWhen(params, k, "zero")
}
for _, k := range []string{"a", "b", "c", "d", "e"} {
if _, ok := params[k]; ok {
t.Errorf("expected key %q to be dropped by omitWhen=zero", k)
}
}
for _, k := range []string{"f", "g"} {
if _, ok := params[k]; !ok {
t.Errorf("expected key %q to be preserved", k)
}
}
})
t.Run("mode_never_preserves_zero_values", func(t *testing.T) {
params := map[string]any{"explicitFalse": false, "explicitZero": 0}
applyOmitWhen(params, "explicitFalse", "never")
applyOmitWhen(params, "explicitZero", "never")
if len(params) != 2 {
t.Errorf("expected both keys preserved under 'never', got %v", params)
}
})
t.Run("mode_empty_is_noop", func(t *testing.T) {
params := map[string]any{"a": ""}
applyOmitWhen(params, "a", "empty")
if _, ok := params["a"]; !ok {
t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)")
}
})
t.Run("missing_key_safe", func(t *testing.T) {
params := map[string]any{}
applyOmitWhen(params, "nope", "zero")
if len(params) != 0 {
t.Errorf("expected no-op on missing key, got %v", params)
}
})
}
// TestIsZeroValue covers every branch of the helper.
func TestIsZeroValue(t *testing.T) {
t.Parallel()
zeros := []any{
nil,
"",
" ",
false,
0,
int64(0),
float64(0),
[]any{},
[]string{},
map[string]any{},
}
for i, z := range zeros {
if !isZeroValue(z) {
t.Errorf("case %d: expected zero value for %#v", i, z)
}
}
nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}}
for i, nz := range nonZeros {
if isZeroValue(nz) {
t.Errorf("case %d: expected non-zero for %#v", i, nz)
}
}
}
// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve.
func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) {
// NOTE: not t.Parallel — edition.Get() global state is shared.
resolvers := runtimeDefaultResolvers()
now := resolvers["$now"]
if now == nil {
t.Fatal("$now resolver missing")
}
if v, ok := now(context.Background()); !ok || v == "" {
t.Errorf("$now returned empty value: %q ok=%v", v, ok)
}
today := resolvers["$today"]
if today == nil {
t.Fatal("$today resolver missing")
}
if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") {
t.Errorf("$today returned unexpected value: %q ok=%v", v, ok)
}
}
// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook.
func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) {
prev := edition.Get()
defer edition.Override(prev)
edition.Override(&edition.Hooks{
RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$currentUserId": func(ctx context.Context) (string, bool) {
return "test-user-001", true
},
}
},
})
resolvers := runtimeDefaultResolvers()
fn := resolvers["$currentUserId"]
if fn == nil {
t.Fatal("$currentUserId missing after overlay install")
}
if v, ok := fn(context.Background()); !ok || v != "test-user-001" {
t.Errorf("$currentUserId=%q ok=%v", v, ok)
}
}
-464
View File
@@ -1,464 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package discovery
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_TENANT",
Category: configmeta.CategoryCore,
Description: "缓存分区的租户标识",
DefaultValue: "default",
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_AUTH_IDENTITY",
Category: configmeta.CategorySecurity,
Description: "缓存分区的认证身份标识",
DefaultValue: "default",
})
}
const (
tenantEnv = "DWS_TENANT"
authIdentityEnv = "DWS_AUTH_IDENTITY"
)
var errCLIServerSkipped = errors.New("server marked cli.skip")
type Service struct {
MarketClient *market.Client
Transport *transport.Client
Cache *cache.Store
Tenant string
AuthIdentity string
Logger *slog.Logger
// PerServerTimeout overrides the default per-server discovery timeout
// when greater than zero. Useful for tests and for callers that need a
// tighter or looser bound. When zero, defaultPerServerDiscoveryTimeout
// applies.
PerServerTimeout time.Duration
}
type RuntimeServer struct {
Server market.ServerDescriptor `json:"server"`
NegotiatedProtocolVersion string `json:"negotiated_protocol_version"`
Tools []transport.ToolDescriptor `json:"tools"`
Source string `json:"source"`
Degraded bool `json:"degraded"`
}
type RuntimeFailure struct {
ServerKey string
Err error
}
func NewService(marketClient *market.Client, transportClient *transport.Client, cacheStore *cache.Store) *Service {
return &Service{
MarketClient: marketClient,
Transport: transportClient,
Cache: cacheStore,
Tenant: resolveTenant(),
AuthIdentity: resolveAuthIdentity(),
}
}
func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescriptor, error) {
partition := s.partition()
response, err := s.MarketClient.FetchServers(ctx, 200)
if err == nil {
servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL)
_ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
return servers, nil
}
snapshot, freshness, cacheErr := s.Cache.LoadRegistry(partition)
if cacheErr == nil {
servers := append([]market.ServerDescriptor(nil), snapshot.Servers...)
for idx := range servers {
servers[idx].Source = string(freshness) + "_cache"
servers[idx].Degraded = true
}
return servers, nil
}
return nil, fmt.Errorf("discover servers: market fetch failed and no cache available: %w", err)
}
func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.ServerDescriptor) (RuntimeServer, error) {
if server.CLI.Skip {
return RuntimeServer{}, errCLIServerSkipped
}
partition := s.partition()
initialize, err := s.Transport.Initialize(ctx, server.Endpoint)
if err == nil {
// NotifyInitialized is best-effort; log but do not fail on error.
if notifyErr := s.Transport.NotifyInitialized(ctx, server.Endpoint); notifyErr != nil && s.Logger != nil {
s.Logger.Debug("NotifyInitialized failed", "server", server.Key, "error", notifyErr)
}
tools, listErr := s.Transport.ListTools(ctx, server.Endpoint)
if listErr == nil {
runtimeTools := append([]transport.ToolDescriptor(nil), tools.Tools...)
var actionVersions map[string]string
if server.DetailLocator.MCPID > 0 {
if detail, detailErr := s.DiscoverDetail(ctx, server); detailErr == nil {
runtimeTools = mergeRuntimeToolsWithDetail(runtimeTools, detail)
actionVersions = cache.ExtractActionVersions(detail.Result.Tools)
}
}
_ = s.Cache.SaveTools(partition, server.Key, cache.ToolsSnapshot{
ServerKey: server.Key,
ProtocolVersion: initialize.ProtocolVersion,
Tools: runtimeTools,
ActionVersions: actionVersions,
})
server.NegotiatedProtocolVersion = initialize.ProtocolVersion
server.Source = "live_runtime"
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: initialize.ProtocolVersion,
Tools: runtimeTools,
Source: "live_runtime",
Degraded: false,
}, nil
}
err = listErr
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return RuntimeServer{}, err
}
snapshot, freshness, cacheErr := s.Cache.LoadTools(partition, server.Key)
if cacheErr != nil {
return RuntimeServer{}, fmt.Errorf("server %s: runtime discovery failed and no cache available: %w", server.Key, err)
}
server.NegotiatedProtocolVersion = snapshot.ProtocolVersion
server.Source = string(freshness) + "_cache"
server.Degraded = true
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: snapshot.ProtocolVersion,
Tools: snapshot.Tools,
Source: string(freshness) + "_cache",
Degraded: true,
}, nil
}
// defaultPerServerDiscoveryTimeout bounds the time spent discovering tools on
// a single registry-listed server. Tightened to 2s so a slow/unreachable
// server cannot stall every CLI command — a healthy MCP endpoint negotiates
// well under a second. See issue #119.
const defaultPerServerDiscoveryTimeout = 2 * time.Second
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
type discoveryResult struct {
server RuntimeServer
failure *RuntimeFailure
}
perServerTimeout := defaultPerServerDiscoveryTimeout
if s.PerServerTimeout > 0 {
perServerTimeout = s.PerServerTimeout
}
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, srv := range servers {
if !srv.CLI.Skip {
filtered = append(filtered, srv)
}
}
if len(filtered) == 0 {
return nil, nil
}
ch := make(chan discoveryResult, len(filtered))
var wg sync.WaitGroup
for _, srv := range filtered {
wg.Add(1)
go func(server market.ServerDescriptor) {
defer wg.Done()
serverCtx, cancel := context.WithTimeout(ctx, perServerTimeout)
defer cancel()
start := time.Now()
rs, err := s.DiscoverServerRuntime(serverCtx, server)
elapsed := time.Since(start)
if err != nil {
if errors.Is(err, errCLIServerSkipped) {
return
}
if s.Logger != nil {
s.Logger.Warn("server_discovery_failed",
slog.String("server_key", server.Key),
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
slog.String("error", err.Error()),
slog.Bool("is_timeout", errors.Is(err, context.DeadlineExceeded)),
)
}
// Per-server sub-context timed out but parent is still alive:
// try cache fallback instead of reporting a hard failure.
if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
if cached, cacheErr := s.loadServerFromCache(server); cacheErr == nil {
if s.Logger != nil {
s.Logger.Info("server_discovery_cache_fallback",
slog.String("server_key", server.Key),
slog.String("source", cached.Source),
)
}
ch <- discoveryResult{server: cached}
return
}
}
ch <- discoveryResult{failure: &RuntimeFailure{ServerKey: server.Key, Err: err}}
return
}
if s.Logger != nil {
s.Logger.Debug("server_discovery_ok",
slog.String("server_key", server.Key),
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
slog.String("source", rs.Source),
)
}
ch <- discoveryResult{server: rs}
}(srv)
}
go func() {
wg.Wait()
close(ch)
}()
results := make([]RuntimeServer, 0, len(filtered))
failures := make([]RuntimeFailure, 0)
for dr := range ch {
if dr.failure != nil {
failures = append(failures, *dr.failure)
} else {
results = append(results, dr.server)
}
}
return results, failures
}
// loadServerFromCache tries to load a server's tools from cache, returning a
// degraded RuntimeServer. Used as fallback when a per-server discovery timeout
// fires but the parent context is still alive.
func (s *Service) loadServerFromCache(server market.ServerDescriptor) (RuntimeServer, error) {
partition := s.partition()
snapshot, freshness, err := s.Cache.LoadTools(partition, server.Key)
if err != nil {
return RuntimeServer{}, err
}
server.NegotiatedProtocolVersion = snapshot.ProtocolVersion
server.Source = string(freshness) + "_cache"
server.Degraded = true
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: snapshot.ProtocolVersion,
Tools: snapshot.Tools,
Source: string(freshness) + "_cache",
Degraded: true,
}, nil
}
func (s *Service) DiscoverDetail(ctx context.Context, server market.ServerDescriptor) (market.DetailResponse, error) {
partition := s.partition()
var fetchErr error
if detailURL := strings.TrimSpace(server.DetailLocator.DetailURL); detailURL != "" {
detail, err := s.MarketClient.FetchDetailByURL(ctx, detailURL)
if err == nil {
cacheDetailSnapshot(s.Cache, partition, server, server.DetailLocator.MCPID, detail)
s.invalidateToolsIfVersionChanged(partition, server.Key, detail)
return detail, nil
}
fetchErr = err
}
if server.DetailLocator.MCPID > 0 {
detail, err := s.MarketClient.FetchDetail(ctx, server.DetailLocator.MCPID)
if err == nil {
cacheDetailSnapshot(s.Cache, partition, server, server.DetailLocator.MCPID, detail)
s.invalidateToolsIfVersionChanged(partition, server.Key, detail)
return detail, nil
}
fetchErr = err
}
if fetchErr == nil {
fetchErr = fmt.Errorf("server %s does not expose detail locator", server.Key)
}
snapshot, _, cacheErr := s.Cache.LoadDetail(partition, server.Key)
if cacheErr != nil {
return market.DetailResponse{}, fetchErr
}
if server.DetailLocator.MCPID > 0 && snapshot.MCPID != server.DetailLocator.MCPID {
return market.DetailResponse{}, fetchErr
}
var cached market.DetailResponse
if unmarshalErr := json.Unmarshal(snapshot.Payload, &cached); unmarshalErr != nil {
return market.DetailResponse{}, fetchErr
}
return cached, nil
}
// invalidateToolsIfVersionChanged checks whether a fresh Detail API response
// contains actionVersion values that differ from those stored in the cached
// tools snapshot. If any tool's version has changed, the tools cache is
// invalidated so the next DiscoverServerRuntime call re-fetches tools/list.
func (s *Service) invalidateToolsIfVersionChanged(partition, serverKey string, detail market.DetailResponse) {
if !detail.Success || len(detail.Result.Tools) == 0 {
return
}
snapshot, _, err := s.Cache.LoadTools(partition, serverKey)
if err != nil || len(snapshot.ActionVersions) == 0 {
return
}
if cache.HasActionVersionChanged(snapshot.ActionVersions, detail.Result.Tools) {
_ = s.Cache.DeleteTools(partition, serverKey)
}
}
func (s *Service) partition() string {
return fmt.Sprintf("%s/%s", s.Tenant, s.AuthIdentity)
}
func (s *Service) CachePartition() string {
return s.partition()
}
func mergeRuntimeToolsWithDetail(tools []transport.ToolDescriptor, detail market.DetailResponse) []transport.ToolDescriptor {
if !detail.Success || len(detail.Result.Tools) == 0 || len(tools) == 0 {
return tools
}
byName := make(map[string]market.DetailTool, len(detail.Result.Tools))
for _, tool := range detail.Result.Tools {
name := strings.TrimSpace(tool.ToolName)
if name == "" {
continue
}
byName[name] = tool
}
out := make([]transport.ToolDescriptor, 0, len(tools))
for _, tool := range tools {
merged := tool
detailTool, ok := byName[strings.TrimSpace(tool.Name)]
if !ok {
out = append(out, merged)
continue
}
if title := strings.TrimSpace(detailTool.ToolTitle); title != "" {
merged.Title = title
}
if description := strings.TrimSpace(detailTool.ToolDesc); description != "" {
merged.Description = description
}
merged.Sensitive = detailTool.IsSensitive
if inputSchema := parseDetailSchema(detailTool.ToolRequest); len(inputSchema) > 0 {
merged.InputSchema = inputSchema
}
if outputSchema := parseDetailSchema(detailTool.ToolResponse); len(outputSchema) > 0 {
merged.OutputSchema = outputSchema
}
out = append(out, merged)
}
return out
}
func parseDetailSchema(raw string) map[string]any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil
}
object, ok := parsed.(map[string]any)
if !ok {
return nil
}
return object
}
func cacheDetailSnapshot(store *cache.Store, partition string, server market.ServerDescriptor, mcpID int, detail market.DetailResponse) {
if store == nil {
return
}
raw, err := json.Marshal(detail)
if err != nil {
return
}
snapshot := cache.DetailSnapshot{
MCPID: mcpID,
Payload: raw,
}
for _, cacheKey := range detailSnapshotKeys(server) {
_ = store.SaveDetail(partition, cacheKey, snapshot)
}
}
func detailSnapshotKeys(server market.ServerDescriptor) []string {
seen := make(map[string]struct{}, 2)
keys := make([]string, 0, 2)
for _, candidate := range []string{
strings.TrimSpace(server.Key),
strings.TrimSpace(server.CLI.ID),
} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
keys = append(keys, candidate)
}
return keys
}
func resolveTenant() string {
value := strings.TrimSpace(os.Getenv(tenantEnv))
if value == "" {
return "default"
}
return strings.ToLower(value)
}
func resolveAuthIdentity() string {
if value := strings.TrimSpace(os.Getenv(authIdentityEnv)); value != "" {
return strings.ToLower(value)
}
return "default"
}
-459
View File
@@ -1,459 +0,0 @@
package discovery
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
}
// Return one server whose MCP endpoint is this same test server.
resp := map[string]any{
"metadata": map[string]any{"count": 1},
"servers": []map[string]any{{
"server": map[string]any{
"name": "test-server",
"description": "Test server",
"remotes": []map[string]any{{"type": "streamable-http", "url": "PLACEHOLDER"}},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{"mcpId": 0, "status": "active"},
"com.dingtalk.mcp.registry/cli": map[string]any{"id": "test", "command": "test", "description": "Test CLI"},
},
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
if !mcpOK {
http.Error(w, "mcp unavailable", http.StatusInternalServerError)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
method, _ := req["method"].(string)
id := req["id"]
switch method {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "test", "version": "1.0"},
},
})
case "notifications/initialized":
// Notification — no response needed, but respond 200 with empty.
w.WriteHeader(http.StatusOK)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"tools": []map[string]any{{
"name": "search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
}},
},
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": -32601, "message": "method not found"},
})
}
})
return httptest.NewServer(mux)
}
func newTestService(t *testing.T, baseURL string, mcpServer *httptest.Server) *Service {
t.Helper()
cacheDir := t.TempDir()
return &Service{
MarketClient: market.NewClient(baseURL, mcpServer.Client()),
Transport: transport.NewClient(mcpServer.Client()),
Cache: cache.NewStore(cacheDir),
Tenant: "test-tenant",
AuthIdentity: "test-identity",
}
}
func TestDiscoverServers_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers")
}
if servers[0].Source != "live_market" {
t.Fatalf("Source = %q, want live_market", servers[0].Source)
}
}
func TestDiscoverServers_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
// Populate cache with a live fetch first.
_, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("initial DiscoverServers() error = %v", err)
}
// Now point market at a broken URL so the next fetch fails.
svc.MarketClient = market.NewClient("http://127.0.0.1:1/broken", nil)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() with cache fallback error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers from cache")
}
if !servers[0].Degraded {
t.Fatal("cached servers should be marked Degraded")
}
}
func TestDiscoverServers_NoMarketNoCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, false, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
_, err := svc.DiscoverServers(context.Background())
if err == nil {
t.Fatal("DiscoverServers() error = nil, want error when both market and cache fail")
}
}
func TestDiscoverServerRuntime_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "test-key",
Endpoint: srv.URL + "/mcp",
}
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() error = %v", err)
}
if result.Source != "live_runtime" {
t.Fatalf("Source = %q, want live_runtime", result.Source)
}
if result.Degraded {
t.Fatal("live result should not be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("expected at least one tool")
}
if result.Tools[0].Name != "search" {
t.Fatalf("tool name = %q, want search", result.Tools[0].Name)
}
if result.NegotiatedProtocolVersion != "2025-03-26" {
t.Fatalf("protocol = %q, want 2025-03-26", result.NegotiatedProtocolVersion)
}
}
func TestDiscoverServerRuntime_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "cache-test",
Endpoint: srv.URL + "/mcp",
}
// First call populates cache.
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("initial DiscoverServerRuntime() error = %v", err)
}
// Now use a broken MCP endpoint so initialize fails.
server.Endpoint = "http://127.0.0.1:1/broken"
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() with cache fallback error = %v", err)
}
if !result.Degraded {
t.Fatal("cached result should be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("cached tools should be present")
}
}
func TestDiscoverServerRuntime_NoCacheNoLive(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "fail-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want error")
}
// Error should contain server key for debugging.
if got := err.Error(); !contains(got, "fail-key") {
t.Fatalf("error %q should contain server key 'fail-key'", got)
}
}
func TestDiscoverServerRuntime_ContextCanceled(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
server := market.ServerDescriptor{
Key: "cancel-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(ctx, server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want context canceled")
}
}
func TestDiscoverAllRuntime_PartialFailure(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{Key: "good", Endpoint: srv.URL + "/mcp"},
{Key: "bad", Endpoint: "http://127.0.0.1:1/broken"},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "good" {
t.Fatalf("successful server key = %q, want good", results[0].Server.Key)
}
if len(failures) != 1 {
t.Fatalf("failures count = %d, want 1", len(failures))
}
if failures[0].ServerKey != "bad" {
t.Fatalf("failed server key = %q, want bad", failures[0].ServerKey)
}
}
func TestDiscoverAllRuntime_SkipsCLISkippedServersWithoutWritingCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{
Key: "active",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "active", Command: "active"},
},
{
Key: "skipped",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(failures) != 0 {
t.Fatalf("failures count = %d, want 0", len(failures))
}
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "active" {
t.Fatalf("successful server key = %q, want active", results[0].Server.Key)
}
if _, _, err := svc.Cache.LoadTools("test-tenant/test-identity", "skipped"); err == nil {
t.Fatal("LoadTools(skipped) error = nil, want skipped service to avoid cache writes")
}
}
func TestCacheDetailSnapshotWritesServerKeyAndCLIID(t *testing.T) {
t.Parallel()
store := cache.NewStore(t.TempDir())
server := market.ServerDescriptor{
Key: "server-key",
CLI: market.CLIOverlay{ID: "server-id"},
}
cacheDetailSnapshot(store, "test-tenant/test-identity", server, 42, market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{{ToolName: "search", ToolTitle: "Search"}},
},
})
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-key"); err != nil {
t.Fatalf("LoadDetail(server-key) error = %v", err)
}
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-id"); err != nil {
t.Fatalf("LoadDetail(server-id) error = %v", err)
}
}
func TestMergeRuntimeToolsWithDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{
{Name: "search", Description: "original desc"},
{Name: "create", Description: "create desc"},
}
detail := market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "search", ToolTitle: "Search Title", ToolDesc: "Updated desc", IsSensitive: true},
},
},
}
merged := mergeRuntimeToolsWithDetail(tools, detail)
if len(merged) != 2 {
t.Fatalf("merged count = %d, want 2", len(merged))
}
// "search" should be updated from detail.
if merged[0].Title != "Search Title" {
t.Fatalf("search title = %q, want 'Search Title'", merged[0].Title)
}
if merged[0].Description != "Updated desc" {
t.Fatalf("search description = %q, want 'Updated desc'", merged[0].Description)
}
if !merged[0].Sensitive {
t.Fatal("search should be marked sensitive")
}
// "create" should remain unchanged.
if merged[1].Description != "create desc" {
t.Fatalf("create description = %q, want original", merged[1].Description)
}
}
func TestMergeRuntimeToolsWithDetail_EmptyDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{{Name: "t1"}}
empty := market.DetailResponse{Success: false}
result := mergeRuntimeToolsWithDetail(tools, empty)
if len(result) != 1 || result[0].Name != "t1" {
t.Fatal("empty detail should return original tools unchanged")
}
}
func TestParseDetailSchema(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNil bool
}{
{"valid object", `{"type":"object"}`, false},
{"empty string", "", true},
{"invalid json", "not-json", true},
{"json array", `[1,2,3]`, true},
{"whitespace only", " ", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDetailSchema(tt.input)
if tt.wantNil && result != nil {
t.Fatalf("parseDetailSchema(%q) = %v, want nil", tt.input, result)
}
if !tt.wantNil && result == nil {
t.Fatalf("parseDetailSchema(%q) = nil, want non-nil", tt.input)
}
})
}
}
func TestPartition(t *testing.T) {
t.Parallel()
svc := &Service{Tenant: "corp1", AuthIdentity: "user1"}
got := svc.partition()
if got != "corp1/user1" {
t.Fatalf("partition() = %q, want corp1/user1", got)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && searchString(s, substr)))
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
-87
View File
@@ -1,87 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package editionmerge converts edition.ServerInfo hooks into
// market.ServerDescriptor values and merges them into discovery results.
//
// This package exists so both internal/cli (runtime catalog loader) and
// internal/app (command-tree loader) can apply the edition's
// SupplementServers / FallbackServers hooks consistently against the same
// discovery pipeline, instead of the hooks being wired only at the
// command-tree layer. Keeping the logic here avoids an import cycle
// between internal/cli ↔ internal/app.
package editionmerge
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// MergeSupplement returns servers augmented with the active edition's
// SupplementServers hook. Discovery entries always win on ID collision —
// the supplement only fills gaps that discovery did not cover.
func MergeSupplement(servers []market.ServerDescriptor) []market.ServerDescriptor {
fn := edition.Get().SupplementServers
if fn == nil {
return servers
}
existing := make(map[string]bool, len(servers))
for _, s := range servers {
if id := s.CLI.ID; id != "" {
existing[id] = true
}
if s.Key != "" {
existing[s.Key] = true
}
}
for _, sup := range fn() {
if sup.ID == "" || existing[sup.ID] {
continue
}
servers = append(servers, ToDescriptor(sup, "edition_supplement"))
}
return servers
}
// FallbackToDescriptors converts the edition's FallbackServers hook into
// market.ServerDescriptor values. Callers should only invoke this when
// live discovery returned zero servers and the cache is also empty.
func FallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
out := make([]market.ServerDescriptor, 0, len(servers))
for _, s := range servers {
out = append(out, ToDescriptor(s, "edition_fallback"))
}
return out
}
// ToDescriptor is the shared conversion from edition.ServerInfo to the
// market descriptor shape expected by downstream consumers.
//
// Source carries the origin tag for diagnostics / metrics. Supplement and
// fallback entries intentionally carry no ToolOverrides — that keeps
// internal/compat.BuildDynamicCommands from materialising parallel
// command trees for products already owned by hardcoded overlays (see
// internal/compat/dynamic_commands.go's CLIOverlay gate).
func ToDescriptor(s edition.ServerInfo, source string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
Source: source,
CLI: market.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
},
}
}
-123
View File
@@ -1,123 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package editionmerge
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func restoreEdition(t *testing.T) {
t.Helper()
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
}
func TestMergeSupplement_DiscoveryWinsOnCollision(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://hardcoded/conference"},
{ID: "doc", Name: "文档(overridden)", Endpoint: "https://hardcoded/doc"},
}
},
})
servers := []market.ServerDescriptor{
{
Key: "doc",
DisplayName: "文档",
Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"},
},
}
merged := MergeSupplement(servers)
if len(merged) != 2 {
t.Fatalf("merged len = %d, want 2", len(merged))
}
byID := make(map[string]market.ServerDescriptor, len(merged))
for _, m := range merged {
byID[m.CLI.ID] = m
}
if got := byID["doc"].Endpoint; got != "https://live/doc" {
t.Errorf("doc endpoint = %q, want live endpoint (discovery wins)", got)
}
if got := byID["conference"].Endpoint; got != "https://hardcoded/conference" {
t.Errorf("conference endpoint = %q, want supplement endpoint", got)
}
if got := byID["conference"].Source; got != "edition_supplement" {
t.Errorf("conference Source = %q, want edition_supplement", got)
}
}
func TestMergeSupplement_NilHookIsNoop(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{})
servers := []market.ServerDescriptor{
{Key: "doc", DisplayName: "文档", Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"}},
}
merged := MergeSupplement(servers)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (no supplement hook registered)", len(merged))
}
}
func TestMergeSupplement_EmptyIDSkipped(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "empty", Endpoint: "https://example.invalid/empty"},
{ID: "valid", Name: "valid", Endpoint: "https://example.invalid/valid"},
}
},
})
merged := MergeSupplement(nil)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (empty ID must be skipped)", len(merged))
}
if merged[0].CLI.ID != "valid" {
t.Errorf("merged[0].CLI.ID = %q, want valid", merged[0].CLI.ID)
}
}
func TestFallbackToDescriptors(t *testing.T) {
got := FallbackToDescriptors([]edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference", Prefixes: []string{"conference", "meeting"}},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
g := got[0]
if g.CLI.ID != "conference" || g.CLI.Command != "conference" {
t.Errorf("CLI overlay not wired: %+v", g.CLI)
}
if g.Source != "edition_fallback" {
t.Errorf("Source = %q, want edition_fallback", g.Source)
}
if len(g.CLI.ToolOverrides) != 0 {
t.Errorf("fallback descriptor must not carry ToolOverrides; got %v", g.CLI.ToolOverrides)
}
}
-16
View File
@@ -20,7 +20,6 @@ import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
)
type Invocation struct {
@@ -60,21 +59,6 @@ func (EchoRunner) Run(_ context.Context, invocation Invocation) (Result, error)
return Result{Invocation: invocation}, nil
}
func NewInvocation(product ir.CanonicalProduct, tool ir.ToolDescriptor, params map[string]any) Invocation {
if params == nil {
params = map[string]any{}
}
return Invocation{
Kind: "canonical_invocation",
Stage: "canonical_cli",
Implemented: false,
CanonicalProduct: product.ID,
Tool: tool.RPCName,
CanonicalPath: tool.CanonicalPath,
Params: params,
}
}
func NewCompatibilityInvocation(legacyPath, canonicalProduct, tool string, params map[string]any) Invocation {
if params == nil {
params = map[string]any{}
-106
View File
@@ -1,106 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package executor
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
)
func TestMergePayloadsAppliesOverridesAfterJSONInputs(t *testing.T) {
t.Parallel()
params, err := MergePayloads(
`{"title":"base","notify":false,"metadata":{"owner":"alice"}}`,
`{"template_id":42}`,
map[string]any{
"title": "override",
"notify": true,
"tags": []any{"alpha", "beta"},
},
)
if err != nil {
t.Fatalf("MergePayloads() error = %v", err)
}
if params["title"] != "override" {
t.Fatalf("MergePayloads() title = %#v, want override", params["title"])
}
if params["template_id"] != float64(42) {
t.Fatalf("MergePayloads() template_id = %#v, want 42", params["template_id"])
}
if params["notify"] != true {
t.Fatalf("MergePayloads() notify = %#v, want true", params["notify"])
}
if _, ok := params["metadata"].(map[string]any); !ok {
t.Fatalf("MergePayloads() expected metadata object to be preserved")
}
}
func TestMergePayloadsRejectsNonObjectJSON(t *testing.T) {
t.Parallel()
if _, err := MergePayloads(`["bad"]`, "", nil); err == nil {
t.Fatalf("MergePayloads() expected validation error for non-object JSON")
}
}
func TestEchoRunnerReturnsInvocation(t *testing.T) {
t.Parallel()
runner := EchoRunner{}
result, err := runner.Run(context.Background(), NewInvocation(
ir.CanonicalProduct{ID: "doc"},
ir.ToolDescriptor{RPCName: "create_document", CanonicalPath: "doc.create_document"},
map[string]any{"title": "spec"},
))
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if result.Invocation.CanonicalPath != "doc.create_document" {
t.Fatalf("Run() canonical path = %q, want doc.create_document", result.Invocation.CanonicalPath)
}
}
func TestEchoRunnerDryRunReturnsToolCallRequestPreview(t *testing.T) {
t.Parallel()
runner := EchoRunner{}
invocation := NewInvocation(
ir.CanonicalProduct{ID: "doc"},
ir.ToolDescriptor{RPCName: "create_document", CanonicalPath: "doc.create_document"},
map[string]any{"title": "spec"},
)
invocation.DryRun = true
result, err := runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if result.Response == nil {
t.Fatal("response = nil, want dry-run preview payload")
}
if result.Response["dry_run"] != true {
t.Fatalf("response.dry_run = %#v, want true", result.Response["dry_run"])
}
request, ok := result.Response["request"].(map[string]any)
if !ok {
t.Fatalf("response.request = %#v, want object", result.Response["request"])
}
if request["method"] != "tools/call" {
t.Fatalf("response.request.method = %#v, want tools/call", request["method"])
}
}
-100
View File
@@ -1,100 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package generator
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
)
const defaultFixturePath = "test/golden/canonical_cli/testdata/catalog_fixture.json"
const defaultSnapshotPath = "docs/generated/schema/catalog.json"
type CatalogSource string
const (
CatalogSourceFixture CatalogSource = "fixture"
CatalogSourceEnv CatalogSource = "env"
CatalogSourceSnapshot CatalogSource = "snapshot"
)
func LoadCatalog(ctx context.Context, fixturePath string) (ir.Catalog, error) {
return LoadCatalogWithSource(ctx, string(CatalogSourceFixture), fixturePath)
}
func LoadCatalogWithSource(ctx context.Context, source string, catalogPath string) (ir.Catalog, error) {
switch normalized := normalizeCatalogSource(source); normalized {
case string(CatalogSourceFixture):
resolvedPath, err := resolveFixturePath(catalogPath)
if err != nil {
return ir.Catalog{}, err
}
return cli.FixtureLoader{Path: resolvedPath}.Load(ctx)
case string(CatalogSourceSnapshot):
resolvedPath, err := resolveSnapshotPath(catalogPath)
if err != nil {
return ir.Catalog{}, err
}
return cli.FixtureLoader{Path: resolvedPath}.Load(ctx)
case string(CatalogSourceEnv):
return cli.NewEnvironmentLoader().Load(ctx)
default:
return ir.Catalog{}, fmt.Errorf("unsupported catalog source %q: must be one of fixture, env, snapshot", normalized)
}
}
func normalizeCatalogSource(source string) string {
source = strings.ToLower(strings.TrimSpace(source))
if source == "" {
return string(CatalogSourceFixture)
}
return source
}
func resolveFixturePath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
if envFixture, ok := os.LookupEnv(cli.CatalogFixtureEnv); ok && strings.TrimSpace(envFixture) != "" {
path = strings.TrimSpace(envFixture)
} else {
path = defaultFixturePath
}
}
return resolveCatalogPath(path)
}
func resolveSnapshotPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
path = defaultSnapshotPath
}
return resolveCatalogPath(path)
}
func resolveCatalogPath(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
return filepath.Join(cwd, path), nil
}
-60
View File
@@ -1,60 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator"
)
func main() {
var source string
var fixture string
var snapshot string
var outputRoot string
flag.StringVar(&source, "source", string(generator.CatalogSourceFixture), "Catalog source: fixture, env, or snapshot")
flag.StringVar(&fixture, "fixture", "", "Path to the catalog fixture that seeds generated outputs")
flag.StringVar(&snapshot, "snapshot", "", "Path to the catalog snapshot used by --source snapshot")
flag.StringVar(&outputRoot, "output-root", ".", "Directory root where generated outputs are written")
flag.Parse()
catalogPath := fixture
if strings.EqualFold(strings.TrimSpace(source), string(generator.CatalogSourceSnapshot)) {
catalogPath = snapshot
}
catalog, err := generator.LoadCatalogWithSource(context.Background(), source, catalogPath)
if err != nil {
fail(err)
}
artifacts, err := generator.Generate(catalog)
if err != nil {
fail(err)
}
if err := generator.WriteArtifacts(outputRoot, artifacts); err != nil {
fail(err)
}
}
func fail(err error) {
_, _ = fmt.Fprintf(os.Stderr, "generate: %v\n", err)
os.Exit(1)
}
-432
View File
@@ -1,432 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
type docsServerExpectation struct {
File string
ServerID string
ServerName string
ServerStatus string
Command string
InitializeResponse any
ToolsListResponse any
}
func main() {
var docsDir string
var outputPath string
var strict bool
flag.StringVar(&docsDir, "docs-dir", "docs/mcp", "Directory containing docs/mcp protocol fixtures")
flag.StringVar(&outputPath, "output", "docs/generated/schema/catalog.json", "Output catalog snapshot path")
flag.BoolVar(&strict, "strict", true, "Fail if no runtime servers can be discovered")
flag.Parse()
expectations, err := loadDocsMCPExpectations(docsDir)
if err != nil {
fail(fmt.Errorf("load docs expectations: %w", err))
}
if len(expectations) == 0 {
fail(fmt.Errorf("no docs/mcp expectations found in %s", docsDir))
}
gateway := newDocsMCPGateway(expectations)
defer gateway.Close()
cacheRoot, err := os.MkdirTemp("", "dws-docs-snapshot-cache-*")
if err != nil {
fail(fmt.Errorf("create temporary cache directory: %w", err))
}
defer func() {
_ = os.RemoveAll(cacheRoot)
}()
service := discovery.NewService(
market.NewClient(gateway.URL, gateway.Client()),
transport.NewClient(gateway.Client()),
cache.NewStore(cacheRoot),
)
servers, err := service.DiscoverServers(context.Background())
if err != nil {
fail(fmt.Errorf("discover servers: %w", err))
}
runtimeServers, failures := service.DiscoverAllRuntime(context.Background(), servers)
if strict && len(runtimeServers) == 0 {
if len(failures) > 0 {
fail(fmt.Errorf("discover runtime failed: %w", failures[0].Err))
}
fail(fmt.Errorf("discover runtime produced no servers"))
}
catalog := ir.BuildCatalog(runtimeServers)
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
fail(fmt.Errorf("encode catalog snapshot: %w", err))
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
fail(fmt.Errorf("create output directory: %w", err))
}
if err := os.WriteFile(outputPath, data, 0o644); err != nil {
fail(fmt.Errorf("write snapshot: %w", err))
}
_, _ = fmt.Fprintf(
os.Stderr,
"generated docs snapshot: output=%s products=%d runtime_ok=%d runtime_fail=%d\n",
outputPath,
len(catalog.Products),
len(runtimeServers),
len(failures),
)
}
func fail(err error) {
_, _ = fmt.Fprintf(os.Stderr, "generate-docs-snapshot: %v\n", err)
os.Exit(1)
}
func loadDocsMCPExpectations(dir string) ([]docsServerExpectation, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
out := make([]docsServerExpectation, 0, len(files))
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read %s: %w", file, err)
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("decode %s: %w", file, err)
}
server := mapValue(payload["server"])
serverID := stringValue(server["id"])
if serverID == "" {
serverID = strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}
command := firstPrefix(server["prefix"])
if command == "" {
command = serverID
}
out = append(out, docsServerExpectation{
File: filepath.Base(file),
ServerID: serverID,
ServerName: stringValue(server["name"]),
ServerStatus: normalizeStatus(stringValue(server["status"])),
Command: command,
InitializeResponse: firstSuccessfulInitializeResponse(payload),
ToolsListResponse: mergedSuccessfulMethodResponse(payload, "tools/list"),
})
}
return out, nil
}
func normalizeStatus(status string) string {
status = strings.TrimSpace(strings.ToLower(status))
if status == "" {
return "active"
}
return status
}
func firstSuccessfulInitializeResponse(payload map[string]any) any {
initialize := mapValue(payload["initialize"])
attempts, ok := sliceValue(initialize["attempts"])
if !ok || len(attempts) == 0 {
return nil
}
var fallback any
for _, raw := range attempts {
attempt := mapValue(raw)
response := attempt["response"]
if fallback == nil && response != nil {
fallback = response
}
if isSuccessfulJSONRPCResponse(response) {
return response
}
}
return fallback
}
func mergedSuccessfulMethodResponse(payload map[string]any, method string) any {
methods := mapValue(payload["methods"])
methodPayload := mapValue(methods[method])
pages, ok := sliceValue(methodPayload["pages"])
if !ok || len(pages) == 0 {
return nil
}
var fallback any
var firstSuccess any
mergedTools := make([]any, 0)
for _, raw := range pages {
page := mapValue(raw)
response := page["response"]
if fallback == nil && response != nil {
fallback = response
}
if !isSuccessfulJSONRPCResponse(response) {
continue
}
if firstSuccess == nil {
firstSuccess = response
}
result := mapValue(mapValue(response)["result"])
tools, ok := sliceValue(result["tools"])
if ok {
mergedTools = append(mergedTools, tools...)
}
}
if firstSuccess == nil {
return fallback
}
firstSuccessMap := mapValue(firstSuccess)
if len(mergedTools) == 0 {
return firstSuccessMap
}
merged := copyMap(firstSuccessMap)
merged["result"] = map[string]any{
"tools": mergedTools,
}
delete(merged, "error")
return merged
}
func isSuccessfulJSONRPCResponse(value any) bool {
response := mapValue(value)
if len(response) == 0 {
return false
}
if errValue, ok := response["error"]; ok && errValue != nil {
return false
}
_, hasResult := response["result"]
return hasResult
}
func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
fixtures := make(map[string]docsServerExpectation, len(expectations))
for _, expected := range expectations {
fixtures[expected.ServerID] = expected
}
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
rows := make([]any, 0, len(expectations))
for idx, expected := range expectations {
endpoint := endpointForServer(server.URL, expected.ServerID)
row := map[string]any{
"server": map[string]any{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": firstNonEmpty(expected.ServerName, expected.ServerID),
"description": firstNonEmpty(expected.ServerName, expected.ServerID),
"remotes": []map[string]any{
{
"type": "streamable-http",
"url": endpoint,
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": expected.ServerStatus,
"updatedAt": "2026-03-22T00:00:00Z",
"publishedAt": "2026-03-22T00:00:00Z",
"mcpId": idx + 1,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": expected.ServerID,
"command": firstNonEmpty(expected.Command, expected.ServerID),
},
},
}
rows = append(rows, row)
}
payload := map[string]any{
"metadata": map[string]any{
"count": len(rows),
},
"servers": rows,
}
writeJSON(w, payload)
})
mux.HandleFunc("/server/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serverID := strings.TrimPrefix(r.URL.Path, "/server/")
fixture, ok := fixtures[serverID]
if !ok {
http.NotFound(w, r)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
method := stringValue(req["method"])
switch method {
case "initialize":
writeJSON(w, valueOrFallback(fixture.InitializeResponse, map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": fixture.ServerID, "version": "1.0.0"},
},
}))
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
writeJSON(w, valueOrFallback(fixture.ToolsListResponse, map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{"tools": []any{}},
}))
default:
http.Error(w, "unexpected json-rpc method", http.StatusBadRequest)
}
})
return server
}
func writeJSON(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload)
}
func valueOrFallback(value, fallback any) any {
if value == nil {
return fallback
}
return value
}
func copyMap(value map[string]any) map[string]any {
if len(value) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(value))
for key, item := range value {
out[key] = item
}
return out
}
func firstNonEmpty(value, fallback string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return strings.TrimSpace(fallback)
}
func endpointForServer(baseURL, serverID string) string {
return strings.TrimRight(baseURL, "/") + "/server/" + serverID
}
func mapValue(value any) map[string]any {
out, _ := value.(map[string]any)
if out == nil {
return map[string]any{}
}
return out
}
func sliceValue(value any) ([]any, bool) {
out, ok := value.([]any)
if !ok {
return nil, false
}
return out, true
}
func firstPrefix(value any) string {
values, ok := sliceValue(value)
if !ok {
return ""
}
for _, raw := range values {
if v := strings.TrimSpace(stringValue(raw)); v != "" {
return v
}
}
return ""
}
func stringValue(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
default:
return ""
}
}
@@ -1,153 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"reflect"
"testing"
)
func TestFirstSuccessfulInitializeResponsePrefersSuccess(t *testing.T) {
t.Parallel()
payload := map[string]any{
"initialize": map[string]any{
"attempts": []any{
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]any{
"code": -32000,
"message": "temporary unavailable",
},
},
},
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{
"protocolVersion": "2025-03-26",
},
},
},
},
},
}
response := firstSuccessfulInitializeResponse(payload)
protocolVersion := stringValue(mapValue(mapValue(response)["result"])["protocolVersion"])
if protocolVersion != "2025-03-26" {
t.Fatalf("firstSuccessfulInitializeResponse() protocolVersion = %q, want 2025-03-26", protocolVersion)
}
}
func TestMergedSuccessfulMethodResponseAggregatesToolsAcrossPages(t *testing.T) {
t.Parallel()
payload := map[string]any{
"methods": map[string]any{
"tools/list": map[string]any{
"pages": []any{
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 2,
"error": map[string]any{
"code": -32603,
"message": "temporary error",
},
},
},
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{
"tools": []any{
map[string]any{"name": "tool_a"},
},
},
},
},
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{
"tools": []any{
map[string]any{"name": "tool_b"},
},
},
},
},
},
},
},
}
response := mergedSuccessfulMethodResponse(payload, "tools/list")
tools := mapValue(mapValue(response)["result"])["tools"]
values, ok := sliceValue(tools)
if !ok {
t.Fatalf("mergedSuccessfulMethodResponse() tools type = %T, want []any", tools)
}
toolNames := make([]string, 0, len(values))
for _, raw := range values {
toolNames = append(toolNames, stringValue(mapValue(raw)["name"]))
}
if !reflect.DeepEqual(toolNames, []string{"tool_a", "tool_b"}) {
t.Fatalf("mergedSuccessfulMethodResponse() tools = %#v, want [tool_a tool_b]", toolNames)
}
}
func TestMergedSuccessfulMethodResponseFallsBackWhenNoSuccess(t *testing.T) {
t.Parallel()
payload := map[string]any{
"methods": map[string]any{
"tools/list": map[string]any{
"pages": []any{
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 2,
"error": map[string]any{
"code": -32601,
"message": "method not found",
},
},
},
map[string]any{
"response": map[string]any{
"jsonrpc": "2.0",
"id": 2,
"error": map[string]any{
"code": -32601,
"message": "method not found",
},
},
},
},
},
},
}
response := mergedSuccessfulMethodResponse(payload, "tools/list")
if mapValue(response)["error"] == nil {
t.Fatalf("mergedSuccessfulMethodResponse() expected fallback error response, got %#v", response)
}
}
-26
View File
@@ -1,26 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package generator
import (
"os"
"path/filepath"
)
func writeFileBytes(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
File diff suppressed because it is too large Load Diff
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package generator
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
)
func TestWriteArtifactsMaterializesFiles(t *testing.T) {
t.Parallel()
root := t.TempDir()
if err := WriteArtifacts(root, []Artifact{
{Path: "docs/generated/example.txt", Content: []byte("example\n")},
}); err != nil {
t.Fatalf("WriteArtifacts() error = %v", err)
}
if _, err := os.Stat(filepath.Join(root, "docs/generated/example.txt")); err != nil {
t.Fatalf("Stat() error = %v", err)
}
}
func TestResolveRequiredServiceSkillsSeparatesMissing(t *testing.T) {
t.Parallel()
available := map[string]struct{}{
"doc": {},
"drive": {},
}
required, missing := resolveRequiredServiceSkills([]string{"doc", "calendar", "drive"}, available)
if len(required) != 2 || required[0] != "dws-doc" || required[1] != "dws-drive" {
t.Fatalf("required = %#v, want [dws-doc dws-drive]", required)
}
if len(missing) != 1 || missing[0] != "dws-calendar" {
t.Fatalf("missing = %#v, want [dws-calendar]", missing)
}
}
func TestFinalizeMarkdownNormalizesTrailingWhitespace(t *testing.T) {
t.Parallel()
input := "line one \r\nline two\t \n\n"
got := finalizeMarkdown(input)
want := "line one\nline two\n"
if got != want {
t.Fatalf("finalizeMarkdown() = %q, want %q", got, want)
}
}
func TestGenerateUsesFlattenedSkillsPaths(t *testing.T) {
t.Parallel()
artifacts, err := Generate(ir.Catalog{})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
paths := map[string]string{}
for _, artifact := range artifacts {
paths[artifact.Path] = string(artifact.Content)
}
for _, want := range []string{
"skills/generated/apis.md",
"skills/generated/canonical-surface/api.md",
"skills/generated/dws-shared/api.md",
} {
if _, ok := paths[want]; !ok {
t.Fatalf("Generate() missing artifact %q", want)
}
}
for _, oldPath := range []string{
"skills/dws/generated/apis.md",
"skills/dws/generated/canonical-surface/api.md",
"skills/dws/generated/dws-shared/api.md",
} {
if _, ok := paths[oldPath]; ok {
t.Fatalf("Generate() still emitted legacy artifact %q", oldPath)
}
}
readme, ok := paths["docs/generated/README.md"]
if !ok {
t.Fatal("Generate() missing docs/generated/README.md")
}
if !strings.Contains(readme, "`skills/generated/apis.md`") {
t.Fatalf("docs/generated/README.md missing flattened skills path:\n%s", readme)
}
if strings.Contains(readme, "`skills/dws/generated/apis.md`") {
t.Fatalf("docs/generated/README.md still references legacy skills path:\n%s", readme)
}
}
-297
View File
@@ -1,297 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package generator
import (
"fmt"
"os"
"slices"
"strings"
registryassets "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"gopkg.in/yaml.v3"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_SKILLS_PERSONAS_FILE",
Category: configmeta.CategoryDebug,
Description: "覆盖内置 personas.yaml 的本地文件路径",
Example: "/path/to/personas.yaml",
Hidden: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_SKILLS_RECIPES_FILE",
Category: configmeta.CategoryDebug,
Description: "覆盖内置 recipes.yaml 的本地文件路径",
Example: "/path/to/recipes.yaml",
Hidden: true,
})
}
const (
PersonaRegistryPathEnv = "DWS_SKILLS_PERSONAS_FILE"
RecipeRegistryPathEnv = "DWS_SKILLS_RECIPES_FILE"
)
type PersonaRegistry struct {
Personas []PersonaEntry `yaml:"personas"`
}
type PersonaEntry struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Services []string `yaml:"services"`
Products []string `yaml:"products,omitempty"` // Backward-compatible alias of services.
Workflows []string `yaml:"workflows"`
Instructions []string `yaml:"instructions"`
Tips []string `yaml:"tips"`
}
type RecipeRegistry struct {
Recipes []RecipeEntry `yaml:"recipes"`
}
type RecipeEntry struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Services []string `yaml:"services"`
Products []string `yaml:"products,omitempty"` // Backward-compatible alias of services.
Steps []string `yaml:"steps"`
Caution string `yaml:"caution"`
}
var knownRegistryProducts = map[string]struct{}{
"aidesign": {},
"aitable": {},
"attendance": {},
"calendar": {},
"chat": {},
"conference": {},
"contact": {},
"devdoc": {},
"ding": {},
"doc": {},
"docparse": {},
"drive": {},
"finance": {},
"law": {},
"live": {},
"mail": {},
"minutes": {},
"oa": {},
"report": {},
"todo": {},
"workbench": {},
}
func loadPersonaRegistry() (PersonaRegistry, error) {
data, err := readRegistryYAML(PersonaRegistryPathEnv, registryassets.PersonasYAML())
if err != nil {
return PersonaRegistry{}, err
}
var registry PersonaRegistry
if err := yaml.Unmarshal(data, &registry); err != nil {
return PersonaRegistry{}, fmt.Errorf("decode personas registry: %w", err)
}
if err := validatePersonaRegistry(registry); err != nil {
return PersonaRegistry{}, err
}
return registry, nil
}
func loadRecipeRegistry() (RecipeRegistry, error) {
data, err := readRegistryYAML(RecipeRegistryPathEnv, registryassets.RecipesYAML())
if err != nil {
return RecipeRegistry{}, err
}
var registry RecipeRegistry
if err := yaml.Unmarshal(data, &registry); err != nil {
return RecipeRegistry{}, fmt.Errorf("decode recipes registry: %w", err)
}
if err := validateRecipeRegistry(registry); err != nil {
return RecipeRegistry{}, err
}
return registry, nil
}
func validatePersonaRegistry(registry PersonaRegistry) error {
if len(registry.Personas) == 0 {
return fmt.Errorf("personas registry is empty")
}
seen := map[string]struct{}{}
for _, persona := range registry.Personas {
name := strings.TrimSpace(persona.Name)
if name == "" {
return fmt.Errorf("personas registry has empty name")
}
if _, ok := seen[name]; ok {
return fmt.Errorf("personas registry has duplicate name %q", name)
}
seen[name] = struct{}{}
if strings.TrimSpace(persona.Title) == "" {
return fmt.Errorf("persona %q missing title", name)
}
if strings.TrimSpace(persona.Description) == "" {
return fmt.Errorf("persona %q missing description", name)
}
services := persona.serviceRefs()
if len(services) == 0 {
return fmt.Errorf("persona %q missing services", name)
}
if len(persona.Instructions) == 0 {
return fmt.Errorf("persona %q missing instructions", name)
}
for _, service := range services {
service = normalizeRegistryToken(service)
if service == "" {
return fmt.Errorf("persona %q has empty service", name)
}
if _, ok := knownRegistryProducts[service]; !ok {
return fmt.Errorf("persona %q references unknown service %q", name, service)
}
}
}
return nil
}
func validateRecipeRegistry(registry RecipeRegistry) error {
if len(registry.Recipes) == 0 {
return fmt.Errorf("recipes registry is empty")
}
seen := map[string]struct{}{}
for _, recipe := range registry.Recipes {
name := strings.TrimSpace(recipe.Name)
if name == "" {
return fmt.Errorf("recipes registry has empty name")
}
if _, ok := seen[name]; ok {
return fmt.Errorf("recipes registry has duplicate name %q", name)
}
seen[name] = struct{}{}
if strings.TrimSpace(recipe.Title) == "" {
return fmt.Errorf("recipe %q missing title", name)
}
if strings.TrimSpace(recipe.Description) == "" {
return fmt.Errorf("recipe %q missing description", name)
}
if strings.TrimSpace(recipe.Category) == "" {
return fmt.Errorf("recipe %q missing category", name)
}
services := recipe.serviceRefs()
if len(services) == 0 {
return fmt.Errorf("recipe %q missing services", name)
}
if len(recipe.Steps) == 0 {
return fmt.Errorf("recipe %q missing steps", name)
}
for _, service := range services {
service = normalizeRegistryToken(service)
if service == "" {
return fmt.Errorf("recipe %q has empty service", name)
}
if _, ok := knownRegistryProducts[service]; !ok {
return fmt.Errorf("recipe %q references unknown service %q", name, service)
}
}
}
return nil
}
func validateRegistryReferences(personas PersonaRegistry, recipes RecipeRegistry) error {
validWorkflows := map[string]struct{}{}
for _, recipe := range recipes.Recipes {
name := normalizeRegistryToken(recipe.Name)
if name == "" {
continue
}
validWorkflows[name] = struct{}{}
validWorkflows["recipe-"+name] = struct{}{}
}
for _, persona := range personas.Personas {
name := strings.TrimSpace(persona.Name)
for _, workflow := range persona.Workflows {
token := normalizeRegistryToken(workflow)
if token == "" {
return fmt.Errorf("persona %q has empty workflow", name)
}
if _, ok := validWorkflows[token]; !ok {
return fmt.Errorf("persona %q references unknown workflow %q", name, workflow)
}
}
}
return nil
}
func uniqueSkillProducts(products []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(products))
for _, product := range products {
product = normalizeRegistryToken(product)
if product == "" {
continue
}
if _, ok := seen[product]; ok {
continue
}
seen[product] = struct{}{}
out = append(out, product)
}
slices.Sort(out)
return out
}
func (p PersonaEntry) serviceRefs() []string {
refs := make([]string, 0, len(p.Services)+len(p.Products))
refs = append(refs, p.Services...)
refs = append(refs, p.Products...)
return uniqueSkillProducts(refs)
}
func (r RecipeEntry) serviceRefs() []string {
refs := make([]string, 0, len(r.Services)+len(r.Products))
refs = append(refs, r.Services...)
refs = append(refs, r.Products...)
return uniqueSkillProducts(refs)
}
func normalizeRegistryToken(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return ""
}
slug := safeSkillSegment(value)
if slug == "unknown" {
return ""
}
return slug
}
func readRegistryYAML(pathEnv string, embedded []byte) ([]byte, error) {
if path, ok := os.LookupEnv(pathEnv); ok && strings.TrimSpace(path) != "" {
data, err := os.ReadFile(strings.TrimSpace(path))
if err != nil {
return nil, fmt.Errorf("read registry file %q: %w", strings.TrimSpace(path), err)
}
return data, nil
}
if len(embedded) == 0 {
return nil, fmt.Errorf("embedded registry content is empty")
}
return embedded, nil
}
-186
View File
@@ -1,186 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package generator
import (
"os"
"path/filepath"
"testing"
)
func TestLoadPersonaRegistry(t *testing.T) {
t.Parallel()
registry, err := loadPersonaRegistry()
if err != nil {
t.Fatalf("loadPersonaRegistry() error = %v", err)
}
if len(registry.Personas) == 0 {
t.Fatal("loadPersonaRegistry() returned empty personas")
}
}
func TestLoadRecipeRegistry(t *testing.T) {
t.Parallel()
registry, err := loadRecipeRegistry()
if err != nil {
t.Fatalf("loadRecipeRegistry() error = %v", err)
}
if len(registry.Recipes) == 0 {
t.Fatal("loadRecipeRegistry() returned empty recipes")
}
}
func TestValidateRegistryReferences(t *testing.T) {
t.Parallel()
personas, err := loadPersonaRegistry()
if err != nil {
t.Fatalf("loadPersonaRegistry() error = %v", err)
}
recipes, err := loadRecipeRegistry()
if err != nil {
t.Fatalf("loadRecipeRegistry() error = %v", err)
}
if err := validateRegistryReferences(personas, recipes); err != nil {
t.Fatalf("validateRegistryReferences() error = %v", err)
}
}
func TestValidateRegistryReferencesRejectsUnknownWorkflow(t *testing.T) {
t.Parallel()
personas := PersonaRegistry{
Personas: []PersonaEntry{
{
Name: "pm",
Title: "PM",
Description: "project manager",
Services: []string{"doc"},
Workflows: []string{"unknown-workflow"},
Instructions: []string{"do work"},
},
},
}
recipes := RecipeRegistry{
Recipes: []RecipeEntry{
{
Name: "meeting-prep",
Title: "Meeting Prep",
Description: "prepare",
Category: "collaboration",
Services: []string{"doc"},
Steps: []string{"step"},
},
},
}
if err := validateRegistryReferences(personas, recipes); err == nil {
t.Fatal("validateRegistryReferences() should fail for unknown workflow")
}
}
func TestUniqueSkillProducts(t *testing.T) {
t.Parallel()
got := uniqueSkillProducts([]string{"doc", "drive", "doc", " DRIVE ", ""})
want := []string{"doc", "drive"}
if len(got) != len(want) {
t.Fatalf("uniqueSkillProducts() len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("uniqueSkillProducts()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestServiceRefsSupportServicesAndProducts(t *testing.T) {
t.Parallel()
persona := PersonaEntry{
Services: []string{"doc", "drive"},
Products: []string{"doc", "chat"},
}
got := persona.serviceRefs()
want := []string{"chat", "doc", "drive"}
if len(got) != len(want) {
t.Fatalf("serviceRefs() len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("serviceRefs()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestNormalizeRegistryToken(t *testing.T) {
t.Parallel()
if got := normalizeRegistryToken(" Meeting_Prep "); got != "meeting-prep" {
t.Fatalf("normalizeRegistryToken() = %q, want %q", got, "meeting-prep")
}
}
func TestValidateRegistryReferencesUsesNormalizedWorkflowName(t *testing.T) {
t.Parallel()
personas := PersonaRegistry{
Personas: []PersonaEntry{
{
Name: "pm",
Title: "PM",
Description: "project manager",
Services: []string{"doc"},
Workflows: []string{"meeting-prep"},
Instructions: []string{"do work"},
},
},
}
recipes := RecipeRegistry{
Recipes: []RecipeEntry{
{
Name: "meeting_prep",
Title: "Meeting Prep",
Description: "prepare",
Category: "collaboration",
Services: []string{"doc"},
Steps: []string{"step"},
},
},
}
if err := validateRegistryReferences(personas, recipes); err != nil {
t.Fatalf("validateRegistryReferences() error = %v", err)
}
}
func TestReadRegistryYAMLFromEnvOverride(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "personas.yaml")
want := []byte("personas: []\n")
if err := os.WriteFile(path, want, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv(PersonaRegistryPathEnv, path)
got, err := readRegistryYAML(PersonaRegistryPathEnv, []byte("fallback"))
if err != nil {
t.Fatalf("readRegistryYAML() error = %v", err)
}
if string(got) != string(want) {
t.Fatalf("readRegistryYAML() = %q, want %q", string(got), string(want))
}
}
+293
View File
@@ -0,0 +1,293 @@
package helpers
import (
"strings"
"github.com/spf13/cobra"
)
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
// 因为后者只在 cobra Execute 完成 mergePersistentFlags 后才包含继承的 flag。
func flagValue(cmd *cobra.Command, name string) string {
if f := cmd.Flag(name); f != nil {
return f.Value.String()
}
return ""
}
func changedFlagValue(cmd *cobra.Command, name string) (string, bool) {
if f := cmd.Flag(name); f != nil && f.Changed {
return f.Value.String(), true
}
return "", false
}
func aisearchFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
if v := flagValue(cmd, primary); v != "" {
return v
}
for _, alias := range aliases {
if v := flagValue(cmd, alias); v != "" {
return v
}
}
return ""
}
func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...string) string {
names := append([]string{primary}, aliases...)
for _, name := range names {
if v, ok := changedFlagValue(cmd, name); ok && v != "" {
return v
}
}
if v := flagValue(cmd, primary); v != "" {
return v
}
return def
}
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
func resolveAisearchKeyword(cmd *cobra.Command) string {
if v := flagValue(cmd, "keyword"); v != "" {
return v
}
for _, alias := range aisearchKeywordAliases {
if v := flagValue(cmd, alias); v != "" {
return v
}
}
return ""
}
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
return validateRequiredFlags(cmd, "keyword")
}
dimensions := parseDimensions(flagValue(cmd, "dimension"))
return callMCPTool("enterprise_person_search", map[string]any{
"keyword": keyword,
"dimension": dimensions,
})
}
// runAisearchEnterprise 调用企业内部知识搜索工具。它关注内容本身,
// 参数只包含内容关键词、内容类型和显式时间范围。
func runAisearchEnterprise(cmd *cobra.Command, _ []string) error {
queries := parseCSVValues(aisearchFlagOrFallback(cmd, "queries", "query", "keyword"))
searchTypes := normalizeAisearchSearchTypes(parseCSVValues(aisearchFlagOrDefault(cmd, "types", "all", "search-types", "searchTypes")))
if len(searchTypes) == 0 {
searchTypes = []string{"all"}
}
toolArgs := map[string]any{
"queries": queries,
"searchTypes": searchTypes,
}
if v := aisearchFlagOrFallback(cmd, "time-range", "timeRange"); v != "" {
toolArgs["timeRange"] = v
}
return callMCPTool("search_enterprise", toolArgs)
}
// runAisearchBehavior 调用企业内部行为记录搜索工具。该能力和 person 同属
// aisearch server,但参数空间不同,因此独立成 behavior 子命令,避免复用
// search/query 这类已经被 person 兜底占用的路径。
func runAisearchBehavior(cmd *cobra.Command, _ []string) error {
queries := parseCSVValues(aisearchFlagOrFallback(cmd, "queries", "query", "keyword"))
searchTypes := normalizeAisearchSearchTypes(parseCSVValues(aisearchFlagOrDefault(cmd, "types", "all", "search-types", "searchTypes")))
if len(searchTypes) == 0 {
searchTypes = []string{"all"}
}
toolArgs := map[string]any{
"queries": queries,
"searchTypes": searchTypes,
}
if v := aisearchFlagOrFallback(cmd, "chat-scope", "chatScope"); v != "" {
toolArgs["chatScope"] = v
}
if v := aisearchFlagOrDefault(cmd, "behavior-type", "all", "behaviorType"); v != "" {
toolArgs["behaviorType"] = v
}
if v := aisearchFlagOrFallback(cmd, "time-range", "timeRange"); v != "" {
toolArgs["timeRange"] = v
}
if v := flagValue(cmd, "direction"); v != "" {
toolArgs["direction"] = v
}
return callMCPTool("search_enterprise_behavior", toolArgs)
}
func normalizeAisearchSearchTypes(values []string) []string {
normalized := make([]string, 0, len(values))
for _, v := range values {
switch strings.ToLower(strings.TrimSpace(v)) {
case "doc", "document":
normalized = append(normalized, "document")
default:
normalized = append(normalized, v)
}
}
return normalized
}
func newAisearchCommand() *cobra.Command {
root := &cobra.Command{
Use: "aisearch",
Short: "AI 搜问",
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
// 检测到 keyword 就自动等价于 person;否则退回 group help。
RunE: func(cmd *cobra.Command, args []string) error {
if resolveAisearchKeyword(cmd) != "" {
return runAisearchPerson(cmd, args)
}
return groupRunE(cmd, args)
},
}
// flag 提到 root 作为 PersistentFlag,这样:
// - dws aisearch --keyword xxx ← root 自己能解析
// - dws aisearch person --keyword xxx ← person 通过继承拿到
// - dws aisearch search --keyword xxx ← search 是 person 的 alias,同上
root.PersistentFlags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
root.PersistentFlags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
// keyword 的同义 flag(隐藏,仅作模型瞎猜兜底)
for _, alias := range aisearchKeywordAliases {
root.PersistentFlags().String(alias, "", "")
_ = root.PersistentFlags().MarkHidden(alias)
}
personCmd := &cobra.Command{
Use: "person",
// alias 列表覆盖真实瞎猜模式(按图里调用频次降序):
// A 类同义瞎猜:search(196) / search-person(74) / user-search(2) /
// user(隐含) / query(5) / people(4) / ask(1) / search-user(2)
// B 类跨模块混淆:contact(2 + 路径变体共 5)
// 所有 alias 透明等价于 person,对外文档/help 仍只展示 person。
Aliases: []string{
"search", "search-person", "search-user",
"user", "user-search",
"query", "people", "ask", "find", "lookup",
"contact",
},
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
// 此时 "search" 会作为 positional arg 被忽略,不报错。
Args: cobra.ArbitraryArgs,
Short: "搜索企业人员",
Long: `通过关键词搜索企业内人员信息,支持按维度筛选。
可选维度 (--dimension):
all 全部维度 (默认)
name 姓名
department 部门
position 职位
duty 职责/技能
supervisor 上级
subordinate 下级
phone 手机号
jobNumber 工号
多个维度用逗号分隔。`,
Example: ` dws aisearch person --keyword "张三" --dimension department
dws aisearch person --keyword "产品部" --dimension department
dws aisearch person --keyword "五道" --dimension supervisor
dws aisearch person --keyword "AI搜问" --dimension duty
dws aisearch person --keyword "李四" --dimension name,department
dws aisearch person --keyword "13800138000" --dimension phone
dws aisearch person --keyword "W12345" --dimension jobNumber`,
RunE: runAisearchPerson,
}
enterpriseCmd := &cobra.Command{
Use: "enterprise",
Aliases: []string{"knowledge", "content", "search-enterprise", "search_enterprise"},
Short: "搜索企业内部知识内容和相关消息",
Long: `检索企业内部知识内容,如文档、消息、日程、待办、听记、日志、图片、链接、AI 表格、企业百科、邮件等。
普通“XX 相关消息/文档/邮件/日程/待办/纪要有哪些”属于企业内容搜索,使用本命令;queries 只放内容关键词,时间放到 --time-range,所有类型词放到 --types。汇总类场景可不传 queries,使用 --types all。
不要把“最近搜索问题相关消息”截断成 --query "搜索问题",也不要把“最近 OKR 相关邮件”写成 --query "OKR 邮件";这会丢失时间和类型槽位。应显式写成 --queries + --types + --time-range。`,
Example: ` dws aisearch enterprise --queries "智能化方案" --types document
dws aisearch enterprise --queries "搜索问题" --types im --time-range "最近"
dws aisearch enterprise --queries "OKR" --types mail --time-range "最近"
dws aisearch enterprise --queries "AI搜问" --types calendar --time-range "本周"
dws aisearch enterprise --queries "项目" --types todo,minute --time-range "最近"
dws aisearch enterprise --queries "发版" --types im --time-range "本周"
dws aisearch enterprise --types all --time-range "本周"
dws aisearch enterprise --queries "OKR" --types document,im,mail`,
RunE: runAisearchEnterprise,
}
enterpriseCmd.Flags().String("queries", "", "内容关键词列表,多个用逗号分隔;汇总类场景可留空")
enterpriseCmd.Flags().String("types", "all", "搜索类型: all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail,多个用逗号分隔")
enterpriseCmd.Flags().String("search-types", "", "--types 的别名")
_ = enterpriseCmd.Flags().MarkHidden("search-types")
enterpriseCmd.Flags().String("searchTypes", "", "--types 的别名")
_ = enterpriseCmd.Flags().MarkHidden("searchTypes")
enterpriseCmd.Flags().String("time-range", "", "时间范围,仅当用户显式给出时间词时填写,如 今天/本周/9月/过去一周")
enterpriseCmd.Flags().String("timeRange", "", "--time-range 的别名")
_ = enterpriseCmd.Flags().MarkHidden("timeRange")
behaviorCmd := &cobra.Command{
Use: "behavior",
Short: "搜索明确的发送/创建/接收等行为记录",
Long: `仅当用户明确询问“我/某人发过、发给、收到、创建、分享、编辑过什么”等行为动作时,检索企业内部行为记录。
普通“XX 相关消息/文档/邮件有哪些”不是行为记录,应使用 aisearch enterprise。behavior 的 queries 只放内容关键词;时间放到 --time-range,所有类型词放到 --types,行为动作放到 --behavior-type,人与人之间的流向放到 --direction。`,
Example: ` dws aisearch behavior --types mail --behavior-type send --direction "我->汐峰"
dws aisearch behavior --types im,mail --behavior-type send --direction "我->汐峰"
dws aisearch behavior --types document --behavior-type receive --direction "汐峰->我"
dws aisearch behavior --types all --behavior-type create --time-range "本周"
dws aisearch behavior --types im --chat-scope "scrum群" --behavior-type send --time-range "今天"`,
RunE: runAisearchBehavior,
}
behaviorCmd.Flags().String("queries", "", "内容关键词列表,多个用逗号分隔;汇总类场景可留空")
behaviorCmd.Flags().String("types", "all", "搜索类型: all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail,多个用逗号分隔")
behaviorCmd.Flags().String("search-types", "", "--types 的别名")
_ = behaviorCmd.Flags().MarkHidden("search-types")
behaviorCmd.Flags().String("searchTypes", "", "--types 的别名")
_ = behaviorCmd.Flags().MarkHidden("searchTypes")
behaviorCmd.Flags().String("chat-scope", "", "消息所在会话/群范围,仅 IM 类型且用户明确指定群名时填写")
behaviorCmd.Flags().String("chatScope", "", "--chat-scope 的别名")
_ = behaviorCmd.Flags().MarkHidden("chatScope")
behaviorCmd.Flags().String("behavior-type", "all", "行为类型: all/send/create/share/edit/receive")
behaviorCmd.Flags().String("behaviorType", "", "--behavior-type 的别名")
_ = behaviorCmd.Flags().MarkHidden("behaviorType")
behaviorCmd.Flags().String("time-range", "", "时间范围,仅当用户显式给出时间词时填写,如 今天/本周/9月/过去一周")
behaviorCmd.Flags().String("timeRange", "", "--time-range 的别名")
_ = behaviorCmd.Flags().MarkHidden("timeRange")
behaviorCmd.Flags().String("direction", "", `交互方向,如 "我->汐峰"、"汐峰->我"、"我<->汐峰"`)
root.AddCommand(personCmd, enterpriseCmd, behaviorCmd)
return root
}
// parseDimensions 将逗号分隔的维度字符串解析为 []string。
func parseDimensions(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return []string{"all"}
}
parts := strings.Split(s, ",")
dims := make([]string, 0, len(parts))
for _, p := range parts {
if d := strings.TrimSpace(p); d != "" {
dims = append(dims, d)
}
}
if len(dims) == 0 {
return []string{"all"}
}
return dims
}
+4871 -744
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More