Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ac0b409d | ||
|
|
4bc4b60dca | ||
|
|
31e65dda51 | ||
|
|
387ae5ff59 | ||
|
|
838e5453d8 | ||
|
|
330922cdee | ||
|
|
eaa60f95b5 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Vendored
+68
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -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` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 条)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user