Compare commits

..
7 Commits
Author SHA1 Message Date
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

The canonical 'dws mcp' tree is built from cached catalog data before the
legacy command build and before Cobra dispatches anything, so a pflag
panic there (a tool schema property named after the reserved --params
flag, as cached during the 1.0.32 incident) aborted every invocation --
including 'dws cache refresh' and 'dws upgrade' -- and sat outside all
three poisoned-cache guards (#447/#449/#452).

Two layers, mirroring the existing guards:
- applyFlagSpecs now skips reserved (--json/--params), duplicate and
  alias-colliding flag names and sanitizes shorthands instead of letting
  pflag panic; the skipped property stays reachable through the reserved
  JSON payload flags (same degradation semantics as #449).
- newMCPCommand wraps the build in the #452 recover -> quarantine ->
  retry-once -> degrade-to-stub sequence, so even an unforeseen panic
  class no longer locks the CLI out.

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00
修雨 4bc4b60dca docs: cut 1.0.36 changelog (#453) 2026-06-10 22:45:50 +08:00
修雨 31e65dda51 fix(cli): self-heal a poisoned discovery cache by quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to <partition>.quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.

dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
2026-06-10 22:45:35 +08:00
修雨 387ae5ff59 fix(doc): strip leading H1 duplicating --name on doc create (#448)
* fix(doc): strip leading H1 duplicating --name on doc create

The doc platform renders the document name as the page title. When the
markdown body also opens with the same H1 — a habit LLM agents fall
into despite the skill docs saying otherwise — the created document
shows the title twice.

Strip a leading ATX H1 from the markdown body when its text equals the
document name (trimmed, case-insensitive), and print a stderr note so
agents learn the convention. Any other leading H1 is kept as
intentional content; names legitimately ending with '#' are not
over-trimmed. When the body is nothing but the duplicate H1, the
markdown param is omitted entirely.

* docs(skill): note the CLI auto-strip of a duplicate leading H1 in doc create

The convention stays the same (--name is the H1, body starts from ##),
but agents should recognize the new stderr note and not rely on the
fallback.
2026-06-10 16:04:20 +08:00
修雨 838e5453d8 fix(compat): guard envelope-driven flag registration against pflag panics (#449)
The discovery envelope is remote data, but four of its shapes were
forwarded to pflag registration calls that panic:

- a flag named 'params' or 'json' collides with the reserved payload
  flags registered at the end of ApplyBindings (the original pre-1.0.32
  lockout: "chat_permission_grant flag redefined: params")
- two bindings resolving to the same long flag name (cross-binding
  duplicate primary/alias; the existing dedup map was per-binding only)
- two flags claiming the same shorthand
- a multi-character shorthand

Because the command tree is built from the cached envelope before Cobra
dispatches anything, any of these aborted every CLI invocation.

Add canRegisterFlag (skip duplicate/reserved long names; CollectBindings
already tolerates missing flags via Lookup→nil→continue, and the value
stays reachable through --params) and safeShorthand (drop invalid or
taken shorthands, keep the long flag) and apply them at every
envelope-driven registration site in ApplyBindings and
registerPositionalAliasFlags. The trailing --json/--params registration
is also made idempotent.

Complements #447: that PR adds the escape hatch when the build panics;
this removes the known panic vectors so the escape hatch should never
be needed for them.
2026-06-10 15:31:37 +08:00
修雨 330922cdee fix(cli): degrade to built-in commands when dynamic build panics (#447)
The dynamic command tree is built from cached discovery data before
Cobra dispatches any command. A panic during that build (e.g. a
duplicate pflag registration fed by a poisoned cache, as seen before
1.0.32: "chat_permission_grant flag redefined: params") aborted every
invocation — including 'dws cache refresh', the very command that
repairs the cache. The only way out was manually deleting
~/.dws/cache/<partition>/tools/*.

Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
2026-06-10 15:28:58 +08:00
xuanandshangguanxuan.sgx eaa60f95b5 feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands

* chore(config): default mcp endpoint to prepub

* chore(config): use prepub mcp discovery host

* fix(devdoc): wrap rag search request

* fix(transport): preserve dingtalk mcp gateway query

* fix(devdoc): align cli with rag mcp schema

* fix(config): keep mcp defaults production-safe

* test(devdoc): cover rag cli parameter mapping

* chore(test): remove dead nested arg assertion

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-09 18:00:05 +08:00
42 changed files with 1908 additions and 66 deletions
+18
View File
@@ -6,6 +6,24 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.36] - 2026-06-10
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
### Added
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
### Fixed
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
## [1.0.35] - 2026-06-08
### Fixed
+2 -2
View File
@@ -492,12 +492,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **330 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
+2 -2
View File
@@ -488,12 +488,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **18 个产品,330 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
+4 -4
View File
@@ -4,7 +4,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environ
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 159
- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
@@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
@@ -182,11 +182,12 @@ _Users, departments, and directory lookups._
_Search the DingTalk Open Platform documentation._
**1 commands**
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
@@ -320,4 +321,3 @@ _Personal todo task management._
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
@@ -0,0 +1,157 @@
// 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)
}
}
+71 -2
View File
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
@@ -49,7 +50,75 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
dynamicCmds := loadDynamicCommands(ctx, runner)
return buildEnvelopeCommandsSafe(ctx, runner)
}
// 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
// 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
}
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)
}
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
@@ -58,7 +127,7 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// command surface remains predictable from the envelope alone.
helpers.AttachReportLegacyInboxAlias(merged, runner)
helpers.AttachReportListReadableEnrichment(merged, runner)
return merged
return merged, nil
}
// pickCommands returns the union of dynamic and helpers commands. For
+203
View File
@@ -0,0 +1,203 @@
// 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))
}
}
+2 -2
View File
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
+69 -1
View File
@@ -682,8 +682,76 @@ func newGenerateSkillsCommand() *cobra.Command {
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.
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.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
return cli.NewMCPCommand(ctx, loader, runner, engine)
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
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
+10
View File
@@ -518,6 +518,16 @@ 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("发现缓存已清空, 新版本首次运行时自动重建"))
}
// Cleanup old backups
rm.Cleanup(5)
+68
View File
@@ -238,6 +238,74 @@ func (s *Store) DeleteDetail(partition, serverKey string) error {
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")
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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)
}
}
+36 -7
View File
@@ -534,6 +534,34 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner
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.
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
}
return cmd.Flags().Lookup(name) == nil
}
// 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 {
return ""
}
if cmd.Flags().ShorthandLookup(short) != nil {
return ""
}
return short
}
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
for _, spec := range specs {
usage := spec.Description
@@ -541,41 +569,42 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
usage = fmt.Sprintf("Override %s", spec.PropertyName)
}
primary := strings.TrimSpace(spec.FlagName)
if primary == "" {
if !canRegisterToolFlag(cmd, primary) {
continue
}
shorthand := safeToolShorthand(cmd, spec.Shorthand)
alias := strings.TrimSpace(spec.Alias)
if alias == primary {
if alias == primary || !canRegisterToolFlag(cmd, alias) {
alias = ""
}
switch spec.Kind {
case flagString, flagJSON:
cmd.Flags().StringP(primary, spec.Shorthand, "", usage)
cmd.Flags().StringP(primary, shorthand, "", usage)
if alias != "" {
cmd.Flags().String(alias, "", usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagInteger:
cmd.Flags().IntP(primary, spec.Shorthand, 0, usage)
cmd.Flags().IntP(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Int(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagNumber:
cmd.Flags().Float64P(primary, spec.Shorthand, 0, usage)
cmd.Flags().Float64P(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Float64(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagBoolean:
cmd.Flags().BoolP(primary, spec.Shorthand, false, usage)
cmd.Flags().BoolP(primary, shorthand, false, usage)
if alias != "" {
cmd.Flags().Bool(alias, false, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
cmd.Flags().StringSliceP(primary, spec.Shorthand, nil, usage)
cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
if alias != "" {
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
+116
View File
@@ -0,0 +1,116 @@
// 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"
"github.com/spf13/cobra"
)
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
// reserved payload flags are registered before the spec loop runs.
func newToolCommandFixture() *cobra.Command {
cmd := &cobra.Command{Use: "probe"}
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
return cmd
}
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
// lock-out: a tool schema property named after a reserved payload flag
// ("params", as cached during the chat_permission_grant incident, or "json")
// must be skipped instead of panicking pflag ("flag redefined") — that panic
// fires while the canonical tree is assembled, before Cobra dispatches
// anything, and used to kill every invocation including `dws cache refresh`
// and `dws upgrade`.
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
{PropertyName: "json", FlagName: "json", Kind: flagString},
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("non-colliding flag --scope was not registered")
}
// The reserved flags must keep their payload usage strings, proving the
// schema-derived specs did not touch them.
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, want the reserved payload usage", got)
}
}
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
// single tool schema (or a spec colliding with an already-applied one).
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
})
flag := cmd.Flags().Lookup("scope")
if flag == nil {
t.Fatalf("--scope was not registered at all")
}
if flag.Usage != "first" {
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
}
}
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
// reserved or existing flag is dropped while the primary still registers.
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("primary flag --scope was not registered when its alias collided")
}
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
}
}
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
// shorthands (both pflag panics) degrade to long-flag-only registration.
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
})
for _, name := range []string{"alpha", "beta", "gamma"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("--%s was not registered", name)
}
}
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
}
}
+1 -1
View File
@@ -381,7 +381,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
"article": {Description: "文档文章"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"search_open_platform_docs": {
"search_open_platform_docs_rag": {
CLIName: "search",
Group: "article",
Flags: map[string]market.CLIFlagOverride{
+50 -11
View File
@@ -415,6 +415,33 @@ func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, de
return
}
// canRegisterFlag reports whether a long flag named name can be registered
// on cmd without panicking pflag ("flag redefined"). The reserved payload
// names are excluded too: ApplyBindings unconditionally registers hidden
// --json/--params after the bindings loop. The envelope is remote data —
// a duplicate or reserved name there must degrade to "flag unavailable",
// never abort the process.
func canRegisterFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
}
return cmd.Flags().Lookup(name) == nil
}
// safeShorthand 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 safeShorthand(cmd *cobra.Command, short string) string {
short = strings.TrimSpace(short)
if len(short) != 1 {
return ""
}
if cmd.Flags().ShorthandLookup(short) != nil {
return ""
}
return short
}
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
for _, binding := range bindings {
// Positional bindings are collected from cobra args rather than flags.
@@ -464,7 +491,7 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
registerHidden := func(name string, suffix string) {
if name == "" {
if !canRegisterFlag(cmd, name) {
return
}
switch binding.Kind {
@@ -484,19 +511,27 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
_ = cmd.Flags().MarkHidden(name)
}
if !canRegisterFlag(cmd, primary) {
// Duplicate or reserved primary name in the envelope. Skip the
// whole binding: CollectBindings tolerates the missing flag
// (Lookup → nil → continue) and the value can still be supplied
// via the --params payload.
continue
}
short := safeShorthand(cmd, binding.Short)
switch binding.Kind {
case ValueString:
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
case ValueInt:
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
case ValueFloat:
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
case ValueBool:
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
case ValueJSON:
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
}
registerHidden(alias, " (alias)")
for _, extra := range extras {
@@ -513,8 +548,12 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
}
}
}
cmd.Flags().String("json", "", "Base JSON object payload for this command")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().String("json", "", "Base JSON object payload for this command")
}
if cmd.Flags().Lookup("params") == nil {
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
}
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("params")
}
@@ -553,12 +592,12 @@ func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
if name == "" {
if !canRegisterFlag(cmd, name) {
return
}
short := ""
if withShort {
short = binding.Short
short = safeShorthand(cmd, binding.Short)
}
usage := binding.Usage + usageSuffix
switch binding.Kind {
+135
View File
@@ -0,0 +1,135 @@
// 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)
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)
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")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func TestResourceName(t *testing.T) {
input string
wantErr bool
}{
{name: "valid", input: "search_open_platform_docs"},
{name: "valid", input: "search_open_platform_docs_rag"},
{name: "valid-cjk", input: "审批查询"},
{name: "leading-digit", input: "1tool", wantErr: true},
{name: "shell-char", input: "tool;rm", wantErr: true},
+78 -1
View File
@@ -56,7 +56,19 @@ func (devdocHandler) Command(runner executor.Runner) *cobra.Command {
},
}
article.AddCommand(newDevdocArticleSearchCommand(runner))
errorCmd := &cobra.Command{
Use: "error",
Short: "错误排查",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
errorCmd.AddCommand(newDevdocErrorDiagnoseCommand(runner))
root.AddCommand(article)
root.AddCommand(errorCmd)
return root
}
@@ -82,7 +94,7 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
if size < 1 {
size = 10
}
return runDevdocTool(cmd, runner, "search_open_platform_docs", map[string]any{
return runDevdocTool(cmd, runner, "search_open_platform_docs_rag", map[string]any{
"keyword": keyword,
"page": page,
"size": size,
@@ -97,6 +109,55 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "diagnose",
Aliases: []string{"troubleshoot"},
Short: "排查开放平台调用错误",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
requestID := devdocFlagOrFallback(cmd, "request-id", "trace-id")
errorCode := devdocFlagOrFallback(cmd, "error-code")
errorMessage := devdocFlagOrFallback(cmd, "error-message")
contextValue := devdocFlagOrFallback(cmd, "context")
query := devdocFlagOrFallback(cmd, "query")
hasPrimaryInput := query != "" || requestID != "" || errorCode != "" || errorMessage != "" || contextValue != ""
if !hasPrimaryInput {
return apperrors.NewValidation("one of --query, --request-id, --error-code, --error-message, or --context is required")
}
combinedQuery := devdocJoinQueryParts(query, errorMessage, devdocFlagOrFallback(cmd, "api"), contextValue)
page, _ := cmd.Flags().GetInt("page")
if page < 1 {
page = 1
}
size, _ := cmd.Flags().GetInt("size")
if size < 1 {
size = 10
}
params := map[string]any{
"page": page,
"size": size,
}
devdocSetStringParam(params, "query", combinedQuery)
devdocSetStringParam(params, "requestId", requestID)
devdocSetStringParam(params, "errorCode", errorCode)
return runDevdocTool(cmd, runner, "search_open_error_code_rag", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "原始排查问题")
cmd.Flags().String("request-id", "", "开放平台 requestId")
addDevdocHiddenStringFlag(cmd, "trace-id", "--request-id 的兼容别名")
cmd.Flags().String("error-code", "", "错误码")
cmd.Flags().String("error-message", "", "错误描述,会合并进原始问题")
cmd.Flags().String("api", "", "API 名称,会合并进原始问题作为补充检索词")
cmd.Flags().String("context", "", "额外排查上下文,会合并进原始问题")
cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)")
cmd.Flags().Int("size", 10, "分页大小 (默认 10)")
return cmd
}
func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
@@ -125,6 +186,22 @@ func devdocFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string)
return ""
}
func devdocSetStringParam(params map[string]any, key, value string) {
if strings.TrimSpace(value) != "" {
params[key] = strings.TrimSpace(value)
}
}
func devdocJoinQueryParts(parts ...string) string {
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
return strings.Join(cleaned, " ")
}
func addDevdocHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
+143 -2
View File
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -43,8 +44,8 @@ func TestDevdocArticleSearchAcceptsWukongKeywordAlias(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "search_open_platform_docs" {
t.Fatalf("tool = %q, want search_open_platform_docs", runner.last.Tool)
if runner.last.Tool != "search_open_platform_docs_rag" {
t.Fatalf("tool = %q, want search_open_platform_docs_rag", runner.last.Tool)
}
if got := runner.last.Params["keyword"]; got != "openConversationId" {
t.Fatalf("keyword = %#v, want openConversationId", got)
@@ -74,3 +75,143 @@ func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) {
t.Fatalf("keyword = %#v, want MCP", got)
}
}
func TestDevdocErrorDiagnosePassesRequestID(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--request-id", "req-123", "--page", "2", "--size", "5"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "search_open_error_code_rag" {
t.Fatalf("tool = %q, want search_open_error_code_rag", runner.last.Tool)
}
if got := runner.last.Params["requestId"]; got != "req-123" {
t.Fatalf("requestId = %#v, want req-123", got)
}
if got := runner.last.Params["page"]; got != 2 {
t.Fatalf("page = %#v, want 2", got)
}
if got := runner.last.Params["size"]; got != 5 {
t.Fatalf("size = %#v, want 5", got)
}
}
func TestDevdocErrorDiagnoseMapsTraceIDAlias(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--trace-id", "trace-abc", "--api", "创建日程"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["requestId"]; got != "trace-abc" {
t.Fatalf("requestId = %#v, want trace-abc", got)
}
if _, ok := runner.last.Params["traceId"]; ok {
t.Fatalf("traceId should not be sent, params = %#v", runner.last.Params)
}
if _, ok := runner.last.Params["apiName"]; ok {
t.Fatalf("apiName should not be sent, params = %#v", runner.last.Params)
}
if got := runner.last.Params["query"]; got != "创建日程" {
t.Fatalf("query = %#v, want 创建日程", got)
}
}
func TestDevdocErrorDiagnosePassesErrorContext(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"error", "troubleshoot",
"--error-code", "33012",
"--error-message", "missing scope",
"--context", "create calendar failed",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["errorCode"]; got != "33012" {
t.Fatalf("errorCode = %#v, want 33012", got)
}
if _, ok := runner.last.Params["errorMessage"]; ok {
t.Fatalf("errorMessage should not be sent, params = %#v", runner.last.Params)
}
if _, ok := runner.last.Params["context"]; ok {
t.Fatalf("context should not be sent, params = %#v", runner.last.Params)
}
if got := runner.last.Params["query"]; got != "missing scope create calendar failed" {
t.Fatalf("query = %#v, want merged error context", got)
}
}
func TestDevdocErrorDiagnoseMergesAllContextIntoQuery(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"error", "diagnose",
"--query", "机器人回调失败",
"--error-message", "missing scope",
"--api", "创建日程",
"--context", "应用无权限",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Tool; got != "search_open_error_code_rag" {
t.Fatalf("tool = %q, want search_open_error_code_rag", got)
}
if got := runner.last.Params["query"]; got != "机器人回调失败 missing scope 创建日程 应用无权限" {
t.Fatalf("query = %#v, want merged context", got)
}
for _, key := range []string{"apiName", "errorMessage", "context"} {
if _, ok := runner.last.Params[key]; ok {
t.Fatalf("%s should not be sent, params = %#v", key, runner.last.Params)
}
}
}
func TestDevdocErrorDiagnoseRequiresTroubleshootInput(t *testing.T) {
t.Parallel()
runner := &devdocCommandRunner{}
cmd := devdocHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"error", "diagnose", "--api", "创建日程"})
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "one of --query") {
t.Fatalf("error = %q, want required input hint", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no call", runner.last.Tool)
}
}
+41 -1
View File
@@ -357,7 +357,13 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
params["markdown"] = content
if stripped, ok := stripLeadingDuplicateTitleHeading(content, name); ok {
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首行与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
content = stripped
}
if content != "" {
params["markdown"] = content
}
}
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
return err
@@ -385,6 +391,40 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
return cmd
}
// stripLeadingDuplicateTitleHeading removes a leading markdown ATX H1 whose
// text equals the document name. The platform already renders the document
// name as the page title, so a body that opens with the same H1 displays the
// title twice (the "two headings" effect). Only an exact match (trimmed,
// case-insensitive) is removed; any other leading H1 is kept as intentional
// content. Reports whether a heading was stripped.
func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" {
return content, false
}
body := strings.TrimLeft(content, "\ufeff \t\r\n")
line := body
rest := ""
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
line = body[:idx]
rest = body[idx+1:]
}
line = strings.TrimRight(line, " \t\r")
if !strings.HasPrefix(line, "# ") {
return content, false
}
heading := strings.TrimSpace(line[2:])
// ATX closing sequence ("# Title #") — only trim trailing hashes when
// separated by a space, so names that legitimately end with '#' survive.
if t := strings.TrimRight(heading, "#"); t != heading && strings.HasSuffix(t, " ") {
heading = strings.TrimSpace(t)
}
if !strings.EqualFold(heading, name) {
return content, false
}
return strings.TrimLeft(rest, "\r\n"), true
}
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
+161
View File
@@ -0,0 +1,161 @@
// 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 helpers
import (
"strings"
"testing"
)
func TestStripLeadingDuplicateTitleHeading(t *testing.T) {
tests := []struct {
name string
content string
docName string
want string
stripped bool
}{
{
name: "exact match stripped",
content: "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。\n",
docName: "2026-06-10 Ari晚会简报",
want: "聚焦今日变化。\n",
stripped: true,
},
{
name: "leading blank lines tolerated",
content: "\n\n# Title\nbody",
docName: "Title",
want: "body",
stripped: true,
},
{
name: "case insensitive match",
content: "# weekly REPORT\nbody",
docName: "Weekly Report",
want: "body",
stripped: true,
},
{
name: "atx closing hashes",
content: "# Title #\nbody",
docName: "Title",
want: "body",
stripped: true,
},
{
name: "title-only content becomes empty",
content: "# Title",
docName: "Title",
want: "",
stripped: true,
},
{
name: "different heading kept",
content: "# 背景\nbody",
docName: "2026-06-10 Ari晚会简报",
want: "# 背景\nbody",
stripped: false,
},
{
name: "h2 not touched",
content: "## Title\nbody",
docName: "Title",
want: "## Title\nbody",
stripped: false,
},
{
name: "no heading kept",
content: "plain text\n# Title later",
docName: "Title",
want: "plain text\n# Title later",
stripped: false,
},
{
name: "name ending with hash not over-trimmed",
content: "# C#\nbody",
docName: "C",
want: "# C#\nbody",
stripped: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, stripped := stripLeadingDuplicateTitleHeading(tc.content, tc.docName)
if stripped != tc.stripped {
t.Fatalf("stripped = %v, want %v", stripped, tc.stripped)
}
if got != tc.want {
t.Fatalf("content = %q, want %q", got, tc.want)
}
})
}
}
// TestDocCreateStripsDuplicateTitleHeading verifies the end-to-end behavior:
// `doc create --name X --content "# X\n..."` must not forward the duplicate
// H1 to the MCP tool — the platform renders the document name as the page
// title, so keeping it would display two headings.
func TestDocCreateStripsDuplicateTitleHeading(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, errOut, err := executeDocCommand(t, root,
"create", "--name", "2026-06-10 Ari晚会简报",
"--content", "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。")
if err != nil {
t.Fatalf("execute: %v", err)
}
got, _ := runner.last.Params["markdown"].(string)
if got != "聚焦今日变化。" {
t.Errorf("markdown param = %q, want duplicate H1 stripped", got)
}
if !strings.Contains(errOut, "已自动移除") {
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
}
}
// TestDocCreateKeepsDistinctHeading ensures the guard never eats an H1 that
// differs from the document name.
func TestDocCreateKeepsDistinctHeading(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, _, err := executeDocCommand(t, root,
"create", "--name", "晚会简报", "--content", "# 背景\n正文")
if err != nil {
t.Fatalf("execute: %v", err)
}
got, _ := runner.last.Params["markdown"].(string)
if got != "# 背景\n正文" {
t.Errorf("markdown param = %q, want content untouched", got)
}
}
// TestDocCreateTitleOnlyContentOmitsMarkdown: when the body is nothing but
// the duplicate H1, the markdown param should be omitted entirely instead of
// sending an empty string.
func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
runner := &docCommandRunner{}
root := newDocTestRoot(runner)
_, _, err := executeDocCommand(t, root,
"create", "--name", "晚会简报", "--content", "# 晚会简报")
if err != nil {
t.Fatalf("execute: %v", err)
}
if _, ok := runner.last.Params["markdown"]; ok {
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ import (
)
const (
defaultBaseURL = "https://mcp.dingtalk.com"
defaultBaseURL = config.DefaultMCPBaseURL
registryMetadataKey = "com.dingtalk.mcp.registry/metadata"
// discoveryAPIPath is the path appended to BaseURL when fetching the
+27 -3
View File
@@ -494,8 +494,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
// Strip any query/fragment from the endpoint to prevent parameter injection.
endpoint = validate.StripQueryFragment(endpoint)
endpoint = sanitizeJSONRPCEndpoint(endpoint)
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
@@ -533,7 +532,7 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
// Diagnostic: log identity-related headers on first attempt.
if attempt == 0 && c.FileLogger != nil {
c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers",
slog.String("endpoint", endpoint),
slog.String("endpoint", RedactURL(endpoint)),
slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
)
@@ -593,6 +592,31 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
)
}
func sanitizeJSONRPCEndpoint(endpoint string) string {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil || parsed.Host == "" {
return validate.StripQueryFragment(endpoint)
}
parsed.Fragment = ""
if shouldPreserveEndpointQuery(parsed) {
return parsed.String()
}
parsed.RawQuery = ""
return parsed.String()
}
func shouldPreserveEndpointQuery(parsed *url.URL) bool {
if parsed == nil || !strings.EqualFold(parsed.Scheme, "https") {
return false
}
switch strings.ToLower(parsed.Hostname()) {
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com":
return true
default:
return false
}
}
func retryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
}
+38
View File
@@ -2,6 +2,9 @@ package transport
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
@@ -83,6 +86,41 @@ func TestRedactURL(t *testing.T) {
}
}
func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
var requestedURL string
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedURL = req.URL.String()
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`)),
Request: req,
}, nil
})})
client.FileLogger = logger
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
if err != nil {
t.Fatalf("doWithRetry() error = %v", err)
}
defer resp.Body.Close()
if !strings.Contains(requestedURL, "key=secret") {
t.Fatalf("gateway request URL = %q, want preserved key query", requestedURL)
}
out := logBuf.String()
if strings.Contains(out, "key=secret") || strings.Contains(out, "secret") {
t.Fatalf("debug log leaked gateway key: %s", out)
}
if !strings.Contains(out, "key=REDACTED") {
t.Fatalf("debug log did not include redacted endpoint, got: %s", out)
}
}
func TestSanitizeBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
+50
View File
@@ -507,6 +507,56 @@ func TestCallToolClassifiesJSONRPCInvalidParamsAsValidationError(t *testing.T) {
}
}
func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
endpoint string
want string
}{
{
name: "prepub gateway",
endpoint: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret",
},
{
name: "prod gateway",
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := sanitizeJSONRPCEndpoint(tt.endpoint); got != tt.want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForOtherHosts(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("https://example.com/server/demo?admin=true#frag")
want := "https://example.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForHTTPGateway(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("http://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag")
want := "http://pre-mcp-gw.dingtalk.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
type testSnapshotRecorder struct {
root string
}
+14
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -194,6 +195,19 @@ func TestDefaultFetchServersLimit(t *testing.T) {
}
}
func TestGetMCPBaseURLDefaultsToProduction(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
got := GetMCPBaseURL()
if got != "https://mcp.dingtalk.com" {
t.Fatalf("GetMCPBaseURL() = %q, want production MCP URL", got)
}
if strings.Contains(got, "pre-mcp") {
t.Fatalf("GetMCPBaseURL() = %q, must not default to prepub MCP URL", got)
}
}
func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
+4 -2
View File
@@ -43,10 +43,12 @@
### 2. devdoc — 开发文档搜索
**用 `devdoc` 的场景**:
- "API 调用报错 403 怎么解决" — 开发调试问题
- "API 调用报错 403 怎么解决" — 走 `devdoc error diagnose`
- "requestId 15r6h45w0muec 为什么失败" — 走 `devdoc error diagnose --request-id ...`
- "搜一下 OAuth2 接入文档" — 开放平台技术文档
- "CLI 命令出错了怎么办" — CLI 使用错误
- 用户提到"开发"、"API"、"调用错误"
- 用户提到"开发"、"API"、"接口文档" → `devdoc article search`
- 用户提到"调用错误"、"错误码"、"requestId"、"traceId" → `devdoc error diagnose`
---
+37
View File
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
dws devdoc error troubleshoot [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (从 1 开始,默认 1)
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
# 已经有 requestId 时排查
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
# 只有 traceId 时按 requestId 兼容处理
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
# 只有错误码和错误描述时排查
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
@@ -41,7 +41,7 @@ Flags:
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
+21
View File
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (默认 1)
--size int 分页大小 (默认 10)
```
---
## oa — 审批
@@ -99,6 +118,7 @@ Example:
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
@@ -111,6 +131,7 @@ Example:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval list-pending` | instanceId | detail / approve / reject |
+3 -1
View File
@@ -28,7 +28,9 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "查 OAuth2 接入文档" | `dws devdoc article search --query "OAuth2 接入"` |
| "API 调用报错怎么办" | `dws devdoc article search --query "<报错关键词>"` |
| "API 调用报错怎么办" | `dws devdoc error diagnose --query "<报错关键词>"` |
| "requestId 15r6h45w0muec 为什么失败" | `dws devdoc error diagnose --request-id 15r6h45w0muec` |
| "错误码 33012" | `dws devdoc error diagnose --error-code 33012` |
| "开放接口文档" | `dws devdoc article search --query "<接口名或场景>"` |
## 跨产品协作
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
dws devdoc error troubleshoot [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (从 1 开始,默认 1)
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
# 已经有 requestId 时排查
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
# 只有 traceId 时按 requestId 兼容处理
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
# 只有错误码和错误描述时排查
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
@@ -41,7 +41,7 @@ Flags:
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (默认 1)
--size int 分页大小 (默认 10)
```
## live — 直播
### 查看我的直播列表
@@ -91,7 +110,8 @@ Args:
## 意图判断
- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search`
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"直播/我的直播" → `live stream list`
- 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接)
@@ -100,6 +120,7 @@ Args:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install <skillId> <target>` |
| `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 |
| `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
+91 -3
View File
@@ -5,7 +5,7 @@ import "testing"
// ── devdoc article search ──────────────────────────────────
func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
cap := setupTestDeps(t, "devdoc")
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "MCP", "page": "1", "size": "10",
@@ -13,16 +13,18 @@ func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "search_open_platform_docs")
assertToolName(t, cap, "search_open_platform_docs_rag")
}
func TestDevdocArticleSearch_should_pass_keyword(t *testing.T) {
cap := setupTestDeps(t, "devdoc")
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "openConversationId", "page": "1", "size": "10",
})
assertToolArg(t, cap, "keyword", "openConversationId")
assertToolArg(t, cap, "page", float64(1))
assertToolArg(t, cap, "size", float64(10))
}
func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
@@ -33,3 +35,89 @@ func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
})
assertCallCount(t, cap, 0)
}
// ── devdoc error diagnose ──────────────────────────────────
func TestDevdocErrorDiagnose_should_call_correct_tool(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "search_open_error_code_rag")
}
func TestDevdocErrorDiagnose_should_pass_request_id(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123", "page": "2", "size": "5",
})
assertToolArg(t, cap, "requestId", "req-123")
assertToolArg(t, cap, "page", float64(2))
assertToolArg(t, cap, "size", float64(5))
}
func TestDevdocErrorDiagnose_should_map_trace_id_alias(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"trace-id": "trace-abc", "api": "创建日程",
})
assertToolArg(t, cap, "requestId", "trace-abc")
assertToolArg(t, cap, "query", "创建日程")
assertArgNotPresent(t, cap, "traceId")
assertArgNotPresent(t, cap, "apiName")
}
func TestDevdocErrorTroubleshootAlias_should_pass_error_context(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "troubleshoot"}, map[string]string{
"error-code": "33012", "error-message": "missing scope", "context": "create calendar failed",
})
assertToolArg(t, cap, "errorCode", "33012")
assertToolArg(t, cap, "query", "missing scope create calendar failed")
}
func TestDevdocErrorDiagnose_should_merge_cli_only_context_into_query(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"query": "机器人回调失败",
"error-message": "missing scope",
"api": "创建日程",
"context": "应用无权限",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolArg(t, cap, "query", "机器人回调失败 missing scope 创建日程 应用无权限")
assertArgNotPresent(t, cap, "apiName")
assertArgNotPresent(t, cap, "errorMessage")
assertArgNotPresent(t, cap, "context")
}
func TestDevdocErrorDiagnose_should_reject_api_without_primary_input(t *testing.T) {
cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"api": "创建日程",
})
if err == nil {
t.Fatal("expected validation error")
}
assertCallCount(t, cap, 0)
}
func TestDevdocErrorDiagnose_should_not_call_when_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
"request-id": "req-123",
})
assertCallCount(t, cap, 0)
}
+20 -2
View File
@@ -37,6 +37,7 @@ type mcpCallCapture struct {
mu sync.Mutex
calls []capturedCall
dryRun bool
preview bool
confirm bool
}
@@ -103,6 +104,13 @@ func setupTestDepsWithDryRun(t *testing.T, product string) *mcpCallCapture {
return cap
}
func setupTestDepsWithPreview(t *testing.T, product string) *mcpCallCapture {
t.Helper()
cap := setupTestDeps(t, product)
cap.preview = true
return cap
}
func setupTestDepsAutoConfirm(t *testing.T, product string) *mcpCallCapture {
t.Helper()
cap := setupTestDeps(t, product)
@@ -130,7 +138,7 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
cliArgs = append(cliArgs, path...)
// Add dry-run if capture says so
if cap != nil && cap.dryRun {
if cap != nil && (cap.dryRun || cap.preview) {
cliArgs = append(cliArgs, "--dry-run")
}
// Add --yes if auto-confirm
@@ -164,12 +172,22 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
DryRun bool `json:"dry_run"`
} `json:"invocation"`
}
var flatInv struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
DryRun bool `json:"dry_run"`
}
if cap != nil {
if jsonErr := json.Unmarshal(out.Bytes(), &inv); jsonErr == nil && inv.Invocation.Tool != "" {
if !inv.Invocation.DryRun {
if !inv.Invocation.DryRun || cap.preview {
cap.record(inv.Invocation.Tool, inv.Invocation.Params, "")
}
// For dry-run: don't record (matches old behavior: assertCallCount == 0)
} else if jsonErr := json.Unmarshal(out.Bytes(), &flatInv); jsonErr == nil && flatInv.Tool != "" {
dryRunPreview := flatInv.DryRun || cap.dryRun || cap.preview
if !dryRunPreview || cap.preview {
cap.record(flatInv.Tool, flatInv.Params, "")
}
}
}
return nil
+5 -5
View File
@@ -156,7 +156,7 @@ func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T
t.Fatalf("list_bases calls = %d, want 1", got)
}
if got := calls.docSearch.Load(); got < 1 {
t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got)
t.Fatalf("search_open_platform_docs_rag calls = %d, want at least 1", got)
}
}
@@ -251,7 +251,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
writeJSONRPCResult(t, w, req["id"], map[string]any{
"tools": []map[string]any{
{
"name": "search_open_platform_docs",
"name": "search_open_platform_docs_rag",
"title": "Search Docs",
"description": "Search docs",
"inputSchema": map[string]any{"type": "object"},
@@ -261,7 +261,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
case "tools/call":
params, _ := req["params"].(map[string]any)
name, _ := params["name"].(string)
if name != "search_open_platform_docs" {
if name != "search_open_platform_docs_rag" {
t.Fatalf("unexpected devdoc tool %q", name)
}
calls.docSearch.Add(1)
@@ -313,8 +313,8 @@ func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string {
"endpoint": baseURL + "/server/devdoc",
"tools": []any{
map[string]any{
"rpc_name": "search_open_platform_docs",
"canonical_path": "devdoc.search_open_platform_docs",
"rpc_name": "search_open_platform_docs_rag",
"canonical_path": "devdoc.search_open_platform_docs_rag",
},
},
},
+14 -2
View File
@@ -44,7 +44,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
| `calendar` | `references/products/calendar.md` | 13 | 30 |
| `chat` | `references/products/chat.md` | 13 | 31 |
| `contact` | `references/products/contact.md` | 7 | 14 |
| `devdoc` | `references/products/simple.md` | 1 | 5 |
| `devdoc` | `references/products/simple.md` | 2 | 7 |
| `ding` | `references/products/ding.md` | 2 | 5 |
| `report` | `references/products/report.md` | 6 | 26 |
| `todo` | `references/products/todo.md` | 6 | 30 |
@@ -1007,7 +1007,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
---
### devdoc(5 条)
### devdoc(7 条)
#### `dws devdoc article search`
@@ -1036,6 +1036,18 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Expected: `dws devdoc article search --keyword 免登鉴权 --format json`
- Flags: `--keyword` = `免登鉴权`
#### `dws devdoc error diagnose`
**devdoc_devdoc_error_diagnose_001**
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
- Flags: `--request-id` = `15r6h45w0muec`
**devdoc_devdoc_error_diagnose_002**
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
- Flags: `--error-code` = `33012`, `--error-message` = `missing scope`
---
### todo(30 条)
+19 -3
View File
@@ -2,8 +2,8 @@
## Summary
- **Total Test Cases**: 202
- **Passed**: 202
- **Total Test Cases**: 204
- **Passed**: 204
- **Failed**: 0
- **Pass Rate**: 100.0%
@@ -16,7 +16,7 @@
| calendar | 30 | 30 | 0 | 100.0% |
| chat | 31 | 31 | 0 | 100.0% |
| contact | 14 | 14 | 0 | 100.0% |
| devdoc | 5 | 5 | 0 | 100.0% |
| devdoc | 7 | 7 | 0 | 100.0% |
| ding | 5 | 5 | 0 | 100.0% |
| report | 26 | 26 | 0 | 100.0% |
| todo | 30 | 30 | 0 | 100.0% |
@@ -1133,6 +1133,22 @@
- Command path: PASS (devdoc article search)
- Flags: PASS (1 flags validated)
**devdoc_devdoc_error_diagnose_001** ✅ PASS
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
- Skill Reference: references/products/devdoc.md
- Command path: PASS (devdoc error diagnose)
- Flags: PASS (1 flags validated)
**devdoc_devdoc_error_diagnose_002** ✅ PASS
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
- Skill Reference: references/products/devdoc.md
- Command path: PASS (devdoc error diagnose)
- Flags: PASS (2 flags validated)
### ding
**ding_ding_message_recall_001** ✅ PASS