* feat(devdoc): add rag mcp cli commands * chore(config): default mcp endpoint to prepub * chore(config): use prepub mcp discovery host * fix(devdoc): wrap rag search request * fix(transport): preserve dingtalk mcp gateway query * fix(devdoc): align cli with rag mcp schema * fix(config): keep mcp defaults production-safe * test(devdoc): cover rag cli parameter mapping * chore(test): remove dead nested arg assertion --------- Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2236 lines
66 KiB
Go
2236 lines
66 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package compat
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
|
)
|
|
|
|
// captureRunner records the most recent invocation for assertion.
|
|
type captureRunner struct {
|
|
lastProduct string
|
|
lastTool string
|
|
lastParams map[string]any
|
|
}
|
|
|
|
func (c *captureRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
|
c.lastProduct = inv.CanonicalProduct
|
|
c.lastTool = inv.Tool
|
|
c.lastParams = inv.Params
|
|
return executor.Result{Invocation: inv}, nil
|
|
}
|
|
|
|
// findChild returns the direct sub-command with the given name, or nil.
|
|
func findChild(parent *cobra.Command, name string) *cobra.Command {
|
|
for _, c := range parent.Commands() {
|
|
if c.Name() == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "group-chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_conversations": {CLIName: "list"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Endpoint: "https://endpoint-bot",
|
|
CLI: market.CLIOverlay{
|
|
ID: "bot",
|
|
Command: "bot",
|
|
Parent: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_robot_message": {CLIName: "send"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
|
|
// Should produce only one top-level command: "chat"
|
|
if len(cmds) != 1 {
|
|
names := make([]string, len(cmds))
|
|
for i, c := range cmds {
|
|
names[i] = c.Name()
|
|
}
|
|
t.Fatalf("expected 1 top-level command, got %d: %v", len(cmds), names)
|
|
}
|
|
if cmds[0].Name() != "chat" {
|
|
t.Fatalf("expected top-level command 'chat', got %q", cmds[0].Name())
|
|
}
|
|
|
|
// "bot" should be a sub-command of "chat"
|
|
found := false
|
|
for _, sub := range cmds[0].Commands() {
|
|
if sub.Name() == "bot" {
|
|
found = true
|
|
// "bot" should have its own sub-command "send"
|
|
hasSend := false
|
|
for _, leaf := range sub.Commands() {
|
|
if leaf.Name() == "send" {
|
|
hasSend = true
|
|
}
|
|
}
|
|
if !hasSend {
|
|
t.Fatal("expected 'bot' to have sub-command 'send'")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected 'bot' as sub-command of 'chat'")
|
|
}
|
|
}
|
|
|
|
func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-orphan",
|
|
CLI: market.CLIOverlay{
|
|
ID: "orphan",
|
|
Command: "orphan",
|
|
Parent: "nonexistent",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"do_something": {CLIName: "do"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
|
|
// Parent not found, should fall back to top-level
|
|
if len(cmds) != 1 {
|
|
t.Fatalf("expected 1 top-level command, got %d", len(cmds))
|
|
}
|
|
if cmds[0].Name() != "orphan" {
|
|
t.Fatalf("expected top-level command 'orphan', got %q", cmds[0].Name())
|
|
}
|
|
}
|
|
|
|
// Phase 0 P1 schema extensions -----------------------------------------------
|
|
|
|
// TestBuildDynamicCommands_ShorthandFlag verifies the Shorthand field wires
|
|
// through to cobra's StringP short form.
|
|
func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"conversationId": {
|
|
Alias: "conv",
|
|
Shorthand: "c",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
if len(cmds) != 1 {
|
|
t.Fatalf("expected 1 cmd, got %d", len(cmds))
|
|
}
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
f := send.Flags().Lookup("conv")
|
|
if f == nil {
|
|
t.Fatal("--conv flag missing")
|
|
}
|
|
if f.Shorthand != "c" {
|
|
t.Fatalf("expected shorthand 'c', got %q", f.Shorthand)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_RequiredFlag verifies Required marks the flag via
|
|
// cobra.MarkFlagRequired (recorded under the BashCompletion annotation).
|
|
func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"conversationId": {
|
|
Alias: "conv",
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
f := send.Flags().Lookup("conv")
|
|
if f == nil {
|
|
t.Fatal("--conv flag missing")
|
|
}
|
|
if _, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
|
|
t.Fatalf("expected --conv to be marked required, annotations=%v", f.Annotations)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_RequiredIgnoredWhenPositional verifies that
|
|
// Required is ignored when Positional is true (cobra arity handles it).
|
|
func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"text": {
|
|
Positional: true,
|
|
PositionalIndex: 0,
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
if f := send.Flags().Lookup("text"); f != nil {
|
|
t.Fatalf("positional 'text' must not be registered as flag: %+v", f)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_PositionalArg verifies Positional params are NOT
|
|
// registered as flags and that cobra Args switches to MinimumNArgs.
|
|
func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"text": {
|
|
Positional: true,
|
|
PositionalIndex: 0,
|
|
},
|
|
"conversationId": {
|
|
Alias: "conv",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
if f := send.Flags().Lookup("text"); f != nil {
|
|
t.Fatalf("text should be positional, not flag, but got flag with usage=%q", f.Usage)
|
|
}
|
|
// Non-positional flag should still be present.
|
|
if f := send.Flags().Lookup("conv"); f == nil {
|
|
t.Fatal("--conv flag should still be registered")
|
|
}
|
|
// Validate arity: executing with zero args must fail with cobra's arity error.
|
|
// Execute() walks up to the root, so set args on the root command.
|
|
cmds[0].SetArgs([]string{"send"})
|
|
cmds[0].SilenceUsage = true
|
|
cmds[0].SilenceErrors = true
|
|
send.SilenceUsage = true
|
|
send.SilenceErrors = true
|
|
err := cmds[0].Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error when required positional is missing")
|
|
}
|
|
if !strings.Contains(err.Error(), "arg") {
|
|
t.Fatalf("expected arity error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_PositionalArgInjection verifies that positional
|
|
// args are injected into params[property] when the leaf is invoked.
|
|
func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
captured := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"text": {
|
|
Positional: true,
|
|
PositionalIndex: 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, captured, nil)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
if err := send.RunE(send, []string{"hello world"}); err != nil {
|
|
t.Fatalf("runE: %v", err)
|
|
}
|
|
if captured.lastParams["text"] != "hello world" {
|
|
t.Fatalf("expected params[text]='hello world', got %+v", captured.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_PositionalWithFlagAliases verifies that an
|
|
// envelope binding marked Positional + Alias + Aliases registers visible
|
|
// primary + hidden alias flags AND still accepts the value as a positional
|
|
// arg. Mirrors the dws devdoc article search shape:
|
|
//
|
|
// { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
|
|
func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
build := func() (*cobra.Command, *captureRunner) {
|
|
captured := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-devdoc",
|
|
CLI: market.CLIOverlay{
|
|
ID: "devdoc",
|
|
Command: "devdoc",
|
|
Groups: map[string]market.CLIGroupDef{
|
|
"article": {Description: "文档文章"},
|
|
},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_open_platform_docs_rag": {
|
|
CLIName: "search",
|
|
Group: "article",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
Aliases: []string{"keyword"},
|
|
Required: true,
|
|
Positional: true,
|
|
PositionalIndex: 0,
|
|
Description: "搜索关键词 (必填)",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cmds := BuildDynamicCommands(servers, captured, nil)
|
|
article := findChild(cmds[0], "article")
|
|
if article == nil {
|
|
t.Fatal("article group not found")
|
|
}
|
|
search := findChild(article, "search")
|
|
if search == nil {
|
|
t.Fatal("search leaf not found")
|
|
}
|
|
return search, captured
|
|
}
|
|
|
|
t.Run("flag registration: visible query + hidden keyword", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, _ := build()
|
|
queryFlag := search.Flags().Lookup("query")
|
|
if queryFlag == nil {
|
|
t.Fatal("--query flag should be registered for dual-mode positional")
|
|
}
|
|
if queryFlag.Hidden {
|
|
t.Fatal("--query flag should be visible (not hidden)")
|
|
}
|
|
keywordFlag := search.Flags().Lookup("keyword")
|
|
if keywordFlag == nil {
|
|
t.Fatal("--keyword hidden alias flag should be registered")
|
|
}
|
|
if !keywordFlag.Hidden {
|
|
t.Fatal("--keyword alias flag should be hidden")
|
|
}
|
|
})
|
|
|
|
t.Run("invocation via positional", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, captured := build()
|
|
if err := search.RunE(search, []string{"MCP"}); err != nil {
|
|
t.Fatalf("RunE positional: %v", err)
|
|
}
|
|
if captured.lastParams["keyword"] != "MCP" {
|
|
t.Fatalf("positional: keyword = %v, want MCP", captured.lastParams["keyword"])
|
|
}
|
|
})
|
|
|
|
t.Run("invocation via --query primary flag", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, captured := build()
|
|
if err := search.Flags().Set("query", "MCP"); err != nil {
|
|
t.Fatalf("Set --query: %v", err)
|
|
}
|
|
if err := search.RunE(search, nil); err != nil {
|
|
t.Fatalf("RunE --query: %v", err)
|
|
}
|
|
if captured.lastParams["keyword"] != "MCP" {
|
|
t.Fatalf("--query: keyword = %v, want MCP", captured.lastParams["keyword"])
|
|
}
|
|
})
|
|
|
|
t.Run("invocation via --keyword hidden alias", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, captured := build()
|
|
if err := search.Flags().Set("keyword", "MCP"); err != nil {
|
|
t.Fatalf("Set --keyword: %v", err)
|
|
}
|
|
if err := search.RunE(search, nil); err != nil {
|
|
t.Fatalf("RunE --keyword: %v", err)
|
|
}
|
|
if captured.lastParams["keyword"] != "MCP" {
|
|
t.Fatalf("--keyword: keyword = %v, want MCP", captured.lastParams["keyword"])
|
|
}
|
|
})
|
|
|
|
t.Run("flag wins over positional when both supplied", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, captured := build()
|
|
if err := search.Flags().Set("query", "FROM_FLAG"); err != nil {
|
|
t.Fatalf("Set --query: %v", err)
|
|
}
|
|
if err := search.RunE(search, []string{"FROM_POSITIONAL"}); err != nil {
|
|
t.Fatalf("RunE both: %v", err)
|
|
}
|
|
if captured.lastParams["keyword"] != "FROM_FLAG" {
|
|
t.Fatalf("flag should win: keyword = %v, want FROM_FLAG", captured.lastParams["keyword"])
|
|
}
|
|
})
|
|
|
|
t.Run("missing input returns validation error", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, _ := build()
|
|
err := search.RunE(search, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error when neither flag nor positional was supplied")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "--query") || !strings.Contains(msg, "keyword") {
|
|
t.Fatalf("error message %q should reference both --query and keyword", msg)
|
|
}
|
|
})
|
|
|
|
t.Run("arity allows zero args (relaxed for dual-mode)", func(t *testing.T) {
|
|
t.Parallel()
|
|
search, _ := build()
|
|
// Args validator should accept zero args because --query / --keyword
|
|
// can satisfy the requirement; the validation step in RunE catches
|
|
// the truly-missing case.
|
|
if err := search.Args(search, []string{}); err != nil {
|
|
t.Fatalf("Args([]) should not error for dual-mode positional: %v", err)
|
|
}
|
|
if err := search.Args(search, []string{"MCP"}); err != nil {
|
|
t.Fatalf("Args([MCP]) should not error: %v", err)
|
|
}
|
|
if err := search.Args(search, []string{"MCP", "extra"}); err == nil {
|
|
t.Fatal("Args should cap at totalMax=1, extra arg should error")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestBuildDynamicCommands_PositionalArityMixed verifies that mixing pure
|
|
// positional (required) with dual-mode positional (with flag aliases) yields
|
|
// a RangeArgs validator: pure positional enforces the minimum, dual-mode
|
|
// extends the maximum.
|
|
func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-mixed",
|
|
CLI: market.CLIOverlay{
|
|
ID: "mixed",
|
|
Command: "mixed",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"do_thing": {
|
|
CLIName: "do",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
// pure positional, required: enforces MinimumNArgs(1)
|
|
"target": {
|
|
Positional: true,
|
|
PositionalIndex: 0,
|
|
Required: true,
|
|
},
|
|
// dual-mode positional at slot 1: extends totalMax to 2
|
|
"label": {
|
|
Alias: "label",
|
|
Aliases: []string{"name"},
|
|
Positional: true,
|
|
PositionalIndex: 1,
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
leaf := findChild(cmds[0], "do")
|
|
if leaf == nil {
|
|
t.Fatal("do leaf not found")
|
|
}
|
|
|
|
// 0 args → arity error (pure positional 'target' missing)
|
|
if err := leaf.Args(leaf, []string{}); err == nil {
|
|
t.Fatal("Args([]) should error: pure positional 'target' is required")
|
|
}
|
|
// 1 arg → fills target, label can come from --label flag
|
|
if err := leaf.Args(leaf, []string{"T"}); err != nil {
|
|
t.Fatalf("Args([T]) should succeed: %v", err)
|
|
}
|
|
// 2 args → both positionals satisfied
|
|
if err := leaf.Args(leaf, []string{"T", "L"}); err != nil {
|
|
t.Fatalf("Args([T, L]) should succeed: %v", err)
|
|
}
|
|
// 3 args → exceeds totalMax=2
|
|
if err := leaf.Args(leaf, []string{"T", "L", "extra"}); err == nil {
|
|
t.Fatal("Args([T, L, extra]) should error: totalMax=2")
|
|
}
|
|
|
|
// 1 arg + --label flag should satisfy the validation step in RunE.
|
|
if err := leaf.Flags().Set("label", "L_FROM_FLAG"); err != nil {
|
|
t.Fatalf("Set --label: %v", err)
|
|
}
|
|
if err := leaf.RunE(leaf, []string{"T"}); err != nil {
|
|
t.Fatalf("RunE([T]) with --label flag: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_ServerOverride verifies ServerOverride routes
|
|
// the tool invocation's CanonicalProduct to a different product.
|
|
func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
captured := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_bots": {
|
|
CLIName: "bot-list",
|
|
ServerOverride: "bot",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, captured, nil)
|
|
leaf := findChild(cmds[0], "bot-list")
|
|
if leaf == nil {
|
|
t.Fatal("bot-list leaf not found")
|
|
}
|
|
if err := leaf.RunE(leaf, nil); err != nil {
|
|
t.Fatalf("runE: %v", err)
|
|
}
|
|
if captured.lastProduct != "bot" {
|
|
t.Fatalf("expected CanonicalProduct=bot, got %q", captured.lastProduct)
|
|
}
|
|
if captured.lastTool != "list_bots" {
|
|
t.Fatalf("expected tool=list_bots, got %q", captured.lastTool)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_ServerOverrideFallback verifies ServerOverride
|
|
// falls back to cli.ID when left empty (backwards compat with existing configs).
|
|
func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
captured := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_conversations": {CLIName: "list"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, captured, nil)
|
|
leaf := findChild(cmds[0], "list")
|
|
if leaf == nil {
|
|
t.Fatal("list leaf not found")
|
|
}
|
|
if err := leaf.RunE(leaf, nil); err != nil {
|
|
t.Fatalf("runE: %v", err)
|
|
}
|
|
if captured.lastProduct != "chat" {
|
|
t.Fatalf("expected CanonicalProduct=chat (fallback), got %q", captured.lastProduct)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_DescriptionOverridesUsage verifies that overlay
|
|
// Description wins over the default paramName usage text.
|
|
func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"conversationId": {
|
|
Alias: "conv",
|
|
Description: "Target conversation open ID",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
f := send.Flags().Lookup("conv")
|
|
if f == nil {
|
|
t.Fatal("--conv flag missing")
|
|
}
|
|
if f.Usage != "Target conversation open ID" {
|
|
t.Fatalf("expected custom description, got %q", f.Usage)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema verifies that when
|
|
// the Detail API schema and overlay both define the same param, the overlay's
|
|
// Description survives enrichment.
|
|
func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message": {
|
|
CLIName: "send",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"conversationId": {
|
|
Alias: "conv",
|
|
Description: "Overlay wins",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
details := map[string][]market.DetailTool{
|
|
"chat": {
|
|
{
|
|
ToolName: "send_message",
|
|
ToolRequest: `{"properties":{` +
|
|
`"conversationId":{"type":"string","description":"Schema description"}` +
|
|
`},"required":["conversationId"]}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
|
send := findChild(cmds[0], "send")
|
|
if send == nil {
|
|
t.Fatal("send leaf not found")
|
|
}
|
|
f := send.Flags().Lookup("conv")
|
|
if f == nil {
|
|
t.Fatal("--conv flag missing")
|
|
}
|
|
if f.Usage != "Overlay wins" {
|
|
t.Fatalf("expected overlay description to win, got %q", f.Usage)
|
|
}
|
|
}
|
|
|
|
// Phase 5 P2 schema extensions ----------------------------------------------
|
|
|
|
// TestBuildDynamicCommands_BodyWrapper verifies that bodyWrapper wraps all
|
|
// user-facing params under the named key while keeping internal control
|
|
// keys (prefix '_') at the top level.
|
|
func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-todo",
|
|
CLI: market.CLIOverlay{
|
|
ID: "todo",
|
|
Command: "todo",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"create_todo": {
|
|
CLIName: "create",
|
|
BodyWrapper: "PersonalTodoCreateVO",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"subject": {Alias: "subject", Required: true},
|
|
"dueTime": {Alias: "due", Transform: "iso8601_to_millis"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
create := findChild(cmds[0], "create")
|
|
if create == nil {
|
|
t.Fatal("create leaf not found")
|
|
}
|
|
|
|
cmds[0].SetArgs([]string{"create", "--subject", "buy milk", "--due", "2026-05-01"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
wrap, ok := runner.lastParams["PersonalTodoCreateVO"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected params wrapped under PersonalTodoCreateVO, got %+v", runner.lastParams)
|
|
}
|
|
if wrap["subject"] != "buy milk" {
|
|
t.Fatalf("wrap[subject]=%v, want 'buy milk'", wrap["subject"])
|
|
}
|
|
if due, ok := wrap["dueTime"].(int64); !ok || due == 0 {
|
|
t.Fatalf("wrap[dueTime] must be int64 millis (iso8601_to_millis), got %T %v", wrap["dueTime"], wrap["dueTime"])
|
|
}
|
|
if _, exists := runner.lastParams["subject"]; exists {
|
|
t.Fatalf("bodyWrapper must move 'subject' off the top level, got %+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys verifies that
|
|
// keys starting with '_' stay at the top level so sensitive / _blocked /
|
|
// _yes style confirmation plumbing keeps working.
|
|
func TestBuildDynamicCommands_BodyWrapperPreservesInternalKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
params := map[string]any{
|
|
"subject": "x",
|
|
"_blocked": true,
|
|
"_yes": false,
|
|
}
|
|
wrapParamsIntoBody(params, "Body")
|
|
body, ok := params["Body"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected Body wrapper, got %+v", params)
|
|
}
|
|
if body["subject"] != "x" {
|
|
t.Fatalf("body[subject]=%v, want 'x'", body["subject"])
|
|
}
|
|
if _, has := body["_blocked"]; has {
|
|
t.Fatal("internal _blocked must not be moved into body")
|
|
}
|
|
if _, has := params["_blocked"]; !has {
|
|
t.Fatal("_blocked must remain at top level")
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MutuallyExclusive verifies that cobra refuses to
|
|
// run when two flags from a mutually-exclusive group are set together.
|
|
func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_messages": {
|
|
CLIName: "list",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"groupId": {Alias: "group"},
|
|
"userId": {Alias: "user"},
|
|
"openDingtalkId": {Alias: "open-dingtalk-id"},
|
|
},
|
|
MutuallyExclusive: [][]string{
|
|
{"group", "user", "open-dingtalk-id"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
err := cmds[0].Execute()
|
|
if err == nil {
|
|
t.Fatal("expected mutually-exclusive error, got nil")
|
|
}
|
|
// cobra 1.10+ prints "if any flags in the group [...] are set none of the others can be";
|
|
// earlier versions used "mutually exclusive". Accept either wording.
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
|
|
t.Fatalf("expected mutually-exclusive error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_RequireOneOf verifies that cobra refuses to run
|
|
// when none of the required-one-of flags are set.
|
|
func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_messages": {
|
|
CLIName: "list",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"groupId": {Alias: "group"},
|
|
"userId": {Alias: "user"},
|
|
},
|
|
RequireOneOf: [][]string{
|
|
{"group", "user"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
cmds[0].SetArgs([]string{"list"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
err := cmds[0].Execute()
|
|
if err == nil {
|
|
t.Fatal("expected require-one-of error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "required") && !strings.Contains(err.Error(), "one") {
|
|
t.Fatalf("expected required-one-of error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_RequireOneOfSatisfied verifies that setting one
|
|
// of the required flags lets the command run normally.
|
|
func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_messages": {
|
|
CLIName: "list",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"groupId": {Alias: "group"},
|
|
"userId": {Alias: "user"},
|
|
},
|
|
RequireOneOf: [][]string{{"group", "user"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"list", "--group", "g1"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if runner.lastParams["groupId"] != "g1" {
|
|
t.Fatalf("expected groupId=g1, got %+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_RedirectTo verifies redirectTo replaces the leaf
|
|
// with a stub that only prints the redirect target.
|
|
func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"old_history": {
|
|
CLIName: "history",
|
|
RedirectTo: "dws chat message list",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
history := findChild(cmds[0], "history")
|
|
if history == nil {
|
|
t.Fatal("history stub not found")
|
|
}
|
|
out := &strings.Builder{}
|
|
history.SetOut(out)
|
|
history.SetErr(out)
|
|
if err := history.RunE(history, nil); err != nil {
|
|
t.Fatalf("runE: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), "dws chat message list") {
|
|
t.Fatalf("redirect output missing target, got %q", out.String())
|
|
}
|
|
if runner.lastTool != "" {
|
|
t.Fatalf("redirect must not call a tool, got %q", runner.lastTool)
|
|
}
|
|
// Redirect stubs disable flag parsing, so arbitrary args must not error.
|
|
if history.Flags().Lookup("json") != nil {
|
|
t.Fatal("redirect stub must not register --json / --params flags")
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_Hints verifies cli.hintCommands creates a stub
|
|
// sub-command under the overlay root (or under a named group) that prints
|
|
// the canonical path instead of invoking a tool.
|
|
func TestBuildDynamicCommands_Hints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
Groups: map[string]market.CLIGroupDef{
|
|
"message": {Description: "会话消息"},
|
|
},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_conversations": {CLIName: "list", Group: "message"},
|
|
},
|
|
Hints: map[string]market.CLIHintDef{
|
|
"history": {
|
|
Target: "dws chat message list",
|
|
Description: "migrated to `message list`",
|
|
},
|
|
"purge": {
|
|
Target: "dws chat message delete-all",
|
|
Group: "message",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
root := cmds[0]
|
|
|
|
// history hint attached directly under root.
|
|
history := findChild(root, "history")
|
|
if history == nil {
|
|
t.Fatal("hint 'history' missing under chat root")
|
|
}
|
|
if history.Short != "migrated to `message list`" {
|
|
t.Fatalf("hint description not applied, got %q", history.Short)
|
|
}
|
|
|
|
// purge hint nested under message group.
|
|
msg := findChild(root, "message")
|
|
if msg == nil {
|
|
t.Fatal("message group missing")
|
|
}
|
|
purge := findChild(msg, "purge")
|
|
if purge == nil {
|
|
t.Fatal("hint 'purge' missing under message group")
|
|
}
|
|
|
|
out := &strings.Builder{}
|
|
purge.SetOut(out)
|
|
// Hint commands intentionally exit non-zero so AI agents / scripts
|
|
// notice the redirect; the error message carries the canonical path.
|
|
err := purge.RunE(purge, nil)
|
|
if err == nil {
|
|
t.Fatalf("hint RunE should return an error to surface non-zero exit, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "dws chat message delete-all") {
|
|
t.Fatalf("hint error missing target, got %q", err.Error())
|
|
}
|
|
if !strings.Contains(out.String(), "dws chat message delete-all") {
|
|
t.Fatalf("hint stdout missing target, got %q", out.String())
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_UnknownFlagConstraintSkipped verifies that a
|
|
// stale / malformed mutuallyExclusive referencing an unknown flag is logged
|
|
// and skipped rather than blocking command tree construction.
|
|
func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "chat",
|
|
Command: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_messages": {
|
|
CLIName: "list",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"groupId": {Alias: "group"},
|
|
},
|
|
MutuallyExclusive: [][]string{
|
|
{"group", "does-not-exist"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
list := findChild(cmds[0], "list")
|
|
if list == nil {
|
|
t.Fatal("list leaf not found (constraint validation must not abort build)")
|
|
}
|
|
cmds[0].SetArgs([]string{"list", "--group", "g1"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("command should run despite skipped constraint, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_PrimarySet verifies the primary
|
|
// flag still works when a binding declares extra hidden aliases.
|
|
func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_contact_by_key_word": {
|
|
CLIName: "search",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
Aliases: []string{"keyword"},
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"search", "--query", "hello"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if runner.lastParams["keyword"] != "hello" {
|
|
t.Fatalf("expected keyword=hello, got %+v", runner.lastParams)
|
|
}
|
|
|
|
// The hidden alias must exist on the leaf command but be marked hidden.
|
|
leaf := findChild(cmds[0], "search")
|
|
if leaf == nil {
|
|
t.Fatal("search leaf missing")
|
|
}
|
|
alias := leaf.Flags().Lookup("keyword")
|
|
if alias == nil {
|
|
t.Fatal("hidden alias --keyword not registered")
|
|
}
|
|
if !alias.Hidden {
|
|
t.Fatalf("--keyword must be hidden (got Hidden=false)")
|
|
}
|
|
if primary := leaf.Flags().Lookup("query"); primary == nil || primary.Hidden {
|
|
t.Fatalf("--query must be registered and visible (got %+v)", primary)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet verifies that
|
|
// passing only a hidden alias satisfies Required and routes the value to
|
|
// params[Property]. Regression: pre-fix envelope rejected --keyword with
|
|
// "unknown flag".
|
|
func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_contact_by_key_word": {
|
|
CLIName: "search",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
Aliases: []string{"keyword"},
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute with hidden alias: %v", err)
|
|
}
|
|
if runner.lastParams["keyword"] != "hi" {
|
|
t.Fatalf("expected keyword=hi, got %+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet verifies
|
|
// the self-check fallback: when Required binding has aliases but none are
|
|
// supplied, CollectBindings emits "--<primary> is required".
|
|
func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_contact_by_key_word": {
|
|
CLIName: "search",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
Aliases: []string{"keyword"},
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"search"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
err := cmds[0].Execute()
|
|
if err == nil {
|
|
t.Fatal("expected required error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--query is required") {
|
|
t.Fatalf("expected --query is required, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet verifies
|
|
// that when both the primary and an alias are provided on the CLI, the
|
|
// primary wins (matches cmdutil.FlagOrFallback precedence: primary first).
|
|
func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_contact_by_key_word": {
|
|
CLIName: "search",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
Aliases: []string{"keyword"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if runner.lastParams["keyword"] != "primary" {
|
|
t.Fatalf("expected primary to win (keyword=primary), got %+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_MultiAliasChain verifies a chain
|
|
// of 3+ aliases resolves the value from the first one that is set.
|
|
func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"get_user_info_by_user_ids": {
|
|
CLIName: "get",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"user_id_list": {
|
|
Alias: "ids",
|
|
Aliases: []string{"user-id", "user-ids"},
|
|
Required: true,
|
|
Transform: "csv_to_array",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute with --user-ids: %v", err)
|
|
}
|
|
got, ok := runner.lastParams["user_id_list"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected []any for user_id_list after csv_to_array, got %T (%+v)", runner.lastParams["user_id_list"], runner.lastParams)
|
|
}
|
|
if len(got) != 2 || got[0] != "u1" || got[1] != "u2" {
|
|
t.Fatalf("expected [u1 u2], got %+v", got)
|
|
}
|
|
|
|
// All three hidden aliases must be registered and hidden, primary visible.
|
|
leaf := findChild(cmds[0], "get")
|
|
if leaf == nil {
|
|
t.Fatal("get leaf missing")
|
|
}
|
|
for _, name := range []string{"user-id", "user-ids"} {
|
|
f := leaf.Flags().Lookup(name)
|
|
if f == nil {
|
|
t.Fatalf("alias --%s not registered", name)
|
|
}
|
|
if !f.Hidden {
|
|
t.Fatalf("alias --%s must be hidden", name)
|
|
}
|
|
}
|
|
if p := leaf.Flags().Lookup("ids"); p == nil || p.Hidden {
|
|
t.Fatalf("primary --ids must be visible")
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MultipleAliases_Dedup verifies that reserved
|
|
// names ("json", "params"), duplicates of the primary, duplicates of the
|
|
// single Alias, and intra-slice duplicates are all silently skipped so
|
|
// cobra never double-registers a flag.
|
|
func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-contact",
|
|
CLI: market.CLIOverlay{
|
|
ID: "contact",
|
|
Command: "contact",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"search_contact_by_key_word": {
|
|
CLIName: "search",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"keyword": {
|
|
Alias: "query",
|
|
// Conflicts: query dup with alias, json/params are
|
|
// reserved, keyword appears twice.
|
|
Aliases: []string{"query", "json", "params", "keyword", "keyword"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
|
|
// should build and execute fine.
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if runner.lastParams["keyword"] != "ok" {
|
|
t.Fatalf("expected keyword=ok, got %+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
func TestBuildDynamicCommands_NoParent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-a",
|
|
CLI: market.CLIOverlay{
|
|
ID: "svc-a",
|
|
Command: "alpha",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"tool_a": {CLIName: "run"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Endpoint: "https://endpoint-b",
|
|
CLI: market.CLIOverlay{
|
|
ID: "svc-b",
|
|
Command: "beta",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"tool_b": {CLIName: "exec"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
|
|
if len(cmds) != 2 {
|
|
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
|
|
}
|
|
}
|
|
|
|
// envelope-help-alignment ----------------------------------------------------
|
|
//
|
|
// The next four tests cover the envelope-driven --help upgrades so dws-wukong
|
|
// can ship hardcoded-equivalent --help text purely through Diamond config.
|
|
|
|
// TestBuildDynamicCommands_ExampleField verifies CLIToolOverride.Example flows
|
|
// to cobra.Command.Example, surfacing the "Examples:" section in --help.
|
|
func TestBuildDynamicCommands_ExampleField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const want = " dws oa approval list-forms --cursor 0 --size 100"
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-oa",
|
|
CLI: market.CLIOverlay{
|
|
ID: "oa",
|
|
Command: "oa",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_user_visible_process": {
|
|
CLIName: "list-forms",
|
|
Group: "approval",
|
|
Example: " dws oa approval list-forms --cursor 0 --size 100",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
approval := findChild(cmds[0], "approval")
|
|
if approval == nil {
|
|
t.Fatal("approval group not found")
|
|
}
|
|
leaf := findChild(approval, "list-forms")
|
|
if leaf == nil {
|
|
t.Fatal("list-forms leaf not found")
|
|
}
|
|
if leaf.Example != want {
|
|
t.Fatalf("Example mismatch:\n want: %q\n got: %q", want, leaf.Example)
|
|
}
|
|
if !strings.Contains(leaf.UsageString(), "Examples:") {
|
|
t.Fatalf("expected 'Examples:' section in --help; usage:\n%s", leaf.UsageString())
|
|
}
|
|
}
|
|
|
|
// TestApplyBindings_VisibleFlagDefault_String verifies that
|
|
// CLIFlagOverride.Default flows to cobra String flags so --help shows
|
|
// (default "0"). Hidden-only behavior is unchanged; this is the path that was
|
|
// previously dead code for visible flags.
|
|
func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-oa",
|
|
CLI: market.CLIOverlay{
|
|
ID: "oa",
|
|
Command: "oa",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_user_visible_process": {
|
|
CLIName: "list-forms",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"cursor": {
|
|
Alias: "cursor",
|
|
Type: "string",
|
|
Default: "0",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
leaf := findChild(cmds[0], "list-forms")
|
|
if leaf == nil {
|
|
t.Fatal("list-forms leaf not found")
|
|
}
|
|
f := leaf.Flags().Lookup("cursor")
|
|
if f == nil {
|
|
t.Fatal("--cursor flag missing")
|
|
}
|
|
if f.DefValue != "0" {
|
|
t.Fatalf("expected DefValue=\"0\", got %q", f.DefValue)
|
|
}
|
|
usage := leaf.UsageString()
|
|
if !strings.Contains(usage, `(default "0")`) {
|
|
t.Fatalf("expected --help to contain (default \"0\"); got:\n%s", usage)
|
|
}
|
|
}
|
|
|
|
// TestApplyBindings_VisibleFlagDefault_Int verifies the Int kind path: cobra
|
|
// renders int defaults without quotes, so we look for "(default 100)".
|
|
func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-oa",
|
|
CLI: market.CLIOverlay{
|
|
ID: "oa",
|
|
Command: "oa",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"list_user_visible_process": {
|
|
CLIName: "list-forms",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"pageSize": {
|
|
Alias: "size",
|
|
Type: "int",
|
|
Default: "100",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
leaf := findChild(cmds[0], "list-forms")
|
|
if leaf == nil {
|
|
t.Fatal("list-forms leaf not found")
|
|
}
|
|
f := leaf.Flags().Lookup("size")
|
|
if f == nil {
|
|
t.Fatal("--size flag missing")
|
|
}
|
|
if f.DefValue != "100" {
|
|
t.Fatalf("expected DefValue=\"100\", got %q", f.DefValue)
|
|
}
|
|
if !strings.Contains(leaf.UsageString(), "(default 100)") {
|
|
t.Fatalf("expected --help to contain (default 100); got:\n%s", leaf.UsageString())
|
|
}
|
|
}
|
|
|
|
// TestCollectBindings_DefaultDoesNotImplyChanged guards the lower half of
|
|
// the v3.2 contract: CollectBindings itself must not infer "user changed
|
|
// this flag" from the presence of an envelope Default. Default-driven MCP
|
|
// body injection lives in buildOverrideBindings' normalizer (verified by the
|
|
// TestNormalizer_* tests below) so user-vs-default attribution stays clean
|
|
// — `firstChangedFlag` is the single source of truth for "user typed it".
|
|
func TestCollectBindings_DefaultDoesNotImplyChanged(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bindings := []FlagBinding{
|
|
{
|
|
FlagName: "cursor",
|
|
Property: "cursor",
|
|
Kind: ValueString,
|
|
Default: "0",
|
|
Usage: "page cursor",
|
|
},
|
|
}
|
|
cmd := &cobra.Command{Use: "list-forms"}
|
|
ApplyBindings(cmd, bindings)
|
|
|
|
// Sanity: cobra received the default and would render it in --help.
|
|
if got := cmd.Flags().Lookup("cursor").DefValue; got != "0" {
|
|
t.Fatalf("expected DefValue=\"0\" wired to cobra, got %q", got)
|
|
}
|
|
|
|
// Don't call cmd.SetArgs / cmd.Execute — i.e. user typed nothing.
|
|
params, err := CollectBindings(cmd, bindings, nil)
|
|
if err != nil {
|
|
t.Fatalf("CollectBindings returned error: %v", err)
|
|
}
|
|
if _, ok := params["cursor"]; ok {
|
|
t.Fatalf("default-only flag must not appear in MCP params; got %v", params)
|
|
}
|
|
}
|
|
|
|
// TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted is the v3.2 mirror of
|
|
// the test above: CollectBindings still skips, but the normalizer returned
|
|
// by buildOverrideBindings must inject the envelope default for visible
|
|
// flags so the MCP body matches the hardcoded helper command's behavior
|
|
// (`mustGetFlag(cobra default) → body`). This is the regression that broke
|
|
// `dws oa approval list-forms` when the user omitted --cursor / --size.
|
|
func TestNormalizer_VisibleFlagDefault_InjectsWhenOmitted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
override := market.CLIToolOverride{
|
|
CLIName: "list-forms",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"cursor": {
|
|
Alias: "cursor",
|
|
Type: "int",
|
|
Default: "0",
|
|
},
|
|
"pageSize": {
|
|
Alias: "size",
|
|
Type: "int",
|
|
Default: "100",
|
|
},
|
|
},
|
|
}
|
|
|
|
bindings, normalizer := buildOverrideBindings(override)
|
|
if normalizer == nil {
|
|
t.Fatal("expected non-nil normalizer when defaults are present")
|
|
}
|
|
cmd := &cobra.Command{Use: "list-forms"}
|
|
ApplyBindings(cmd, bindings)
|
|
|
|
params, err := CollectBindings(cmd, bindings, nil)
|
|
if err != nil {
|
|
t.Fatalf("CollectBindings returned error: %v", err)
|
|
}
|
|
if err := normalizer(cmd, params); err != nil {
|
|
t.Fatalf("normalizer returned error: %v", err)
|
|
}
|
|
|
|
if got, ok := params["cursor"].(int); !ok || got != 0 {
|
|
t.Fatalf("expected params[cursor] = int(0), got %T(%v)", params["cursor"], params["cursor"])
|
|
}
|
|
if got, ok := params["pageSize"].(int); !ok || got != 100 {
|
|
t.Fatalf("expected params[pageSize] = int(100), got %T(%v)", params["pageSize"], params["pageSize"])
|
|
}
|
|
}
|
|
|
|
// TestNormalizer_DefaultCoercedByKind covers every ValueKind the envelope
|
|
// type-name dictionary (kindFromTypeName) can actually produce today —
|
|
// string / int / bool / string_slice — so a number-typed schema receives a
|
|
// number (not the raw envelope string) and slice kinds get a []string.
|
|
// Catches regressions in the parseFlagDefault dispatch.
|
|
//
|
|
// ValueFloat and the *_slice numeric kinds aren't reachable from envelope
|
|
// `type` strings yet, but the normalizer still handles them for forward
|
|
// compatibility (asserted indirectly by the build).
|
|
func TestNormalizer_DefaultCoercedByKind(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
override := market.CLIToolOverride{
|
|
CLIName: "demo",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"name": {Alias: "name", Type: "string", Default: "alice"},
|
|
"page": {Alias: "page", Type: "int", Default: "7"},
|
|
"verbose": {Alias: "verbose", Type: "bool", Default: "true"},
|
|
"tags": {Alias: "tags", Type: "string_slice", Default: "a,b,c"},
|
|
},
|
|
}
|
|
|
|
bindings, normalizer := buildOverrideBindings(override)
|
|
if normalizer == nil {
|
|
t.Fatal("expected non-nil normalizer when defaults are present")
|
|
}
|
|
cmd := &cobra.Command{Use: "demo"}
|
|
ApplyBindings(cmd, bindings)
|
|
|
|
params, err := CollectBindings(cmd, bindings, nil)
|
|
if err != nil {
|
|
t.Fatalf("CollectBindings returned error: %v", err)
|
|
}
|
|
if err := normalizer(cmd, params); err != nil {
|
|
t.Fatalf("normalizer returned error: %v", err)
|
|
}
|
|
|
|
if got, ok := params["name"].(string); !ok || got != "alice" {
|
|
t.Fatalf("name: expected string \"alice\", got %T(%v)", params["name"], params["name"])
|
|
}
|
|
if got, ok := params["page"].(int); !ok || got != 7 {
|
|
t.Fatalf("page: expected int 7, got %T(%v)", params["page"], params["page"])
|
|
}
|
|
if got, ok := params["verbose"].(bool); !ok || !got {
|
|
t.Fatalf("verbose: expected bool true, got %T(%v)", params["verbose"], params["verbose"])
|
|
}
|
|
got, ok := params["tags"].([]string)
|
|
if !ok {
|
|
t.Fatalf("tags: expected []string, got %T(%v)", params["tags"], params["tags"])
|
|
}
|
|
if strings.Join(got, ",") != "a,b,c" {
|
|
t.Fatalf("tags: expected [a b c], got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestNormalizer_DefaultDoesNotOverrideUserValue locks in the
|
|
// `if exists continue` guard so user input always beats the envelope default,
|
|
// matching CollectBindings' user-changed-flag wins contract.
|
|
func TestNormalizer_DefaultDoesNotOverrideUserValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
override := market.CLIToolOverride{
|
|
CLIName: "list-forms",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"cursor": {
|
|
Alias: "cursor",
|
|
Type: "int",
|
|
Default: "0",
|
|
},
|
|
},
|
|
}
|
|
|
|
bindings, normalizer := buildOverrideBindings(override)
|
|
if normalizer == nil {
|
|
t.Fatal("expected non-nil normalizer when defaults are present")
|
|
}
|
|
cmd := &cobra.Command{Use: "list-forms"}
|
|
ApplyBindings(cmd, bindings)
|
|
|
|
if err := cmd.ParseFlags([]string{"--cursor", "5"}); err != nil {
|
|
t.Fatalf("ParseFlags returned error: %v", err)
|
|
}
|
|
|
|
params, err := CollectBindings(cmd, bindings, nil)
|
|
if err != nil {
|
|
t.Fatalf("CollectBindings returned error: %v", err)
|
|
}
|
|
if err := normalizer(cmd, params); err != nil {
|
|
t.Fatalf("normalizer returned error: %v", err)
|
|
}
|
|
|
|
if got, ok := params["cursor"].(int); !ok || got != 5 {
|
|
t.Fatalf("expected params[cursor] = int(5), got %T(%v)", params["cursor"], params["cursor"])
|
|
}
|
|
}
|
|
|
|
// parent-merge tests ---------------------------------------------------------
|
|
|
|
// TestBuildDynamicCommands_ParentMergeSameName covers the case where two
|
|
// servers share the same cli.command + cli.parent. Instead of producing two
|
|
// sibling subcommands with the same Name under the parent (which cobra allows
|
|
// but `--help` renders as duplicate rows), the compat layer merges them into
|
|
// a single subcommand whose children are the union of both sides.
|
|
func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "group-chat",
|
|
Command: "chat",
|
|
Groups: map[string]market.CLIGroupDef{
|
|
"message": {Description: "会话消息管理"},
|
|
},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message_as_user": {CLIName: "send", Group: "message"},
|
|
"list_conversation_message_v2": {CLIName: "list", Group: "message"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// Second server contributes more leaves into the same "message"
|
|
// namespace via command="message" + parent="chat".
|
|
Endpoint: "https://endpoint-bot",
|
|
CLI: market.CLIOverlay{
|
|
ID: "bot-message",
|
|
Command: "message",
|
|
Parent: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_robot_group_message": {CLIName: "send-by-bot"},
|
|
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
if len(cmds) != 1 || cmds[0].Name() != "chat" {
|
|
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
|
|
}
|
|
|
|
// There must be exactly one child named "message" under chat, not two.
|
|
var messageCmds []*cobra.Command
|
|
for _, sub := range cmds[0].Commands() {
|
|
if sub.Name() == "message" {
|
|
messageCmds = append(messageCmds, sub)
|
|
}
|
|
}
|
|
if len(messageCmds) != 1 {
|
|
names := make([]string, 0, len(cmds[0].Commands()))
|
|
for _, c := range cmds[0].Commands() {
|
|
names = append(names, c.Name())
|
|
}
|
|
t.Fatalf("expected exactly one 'message' under chat, got %d — chat children: %v", len(messageCmds), names)
|
|
}
|
|
|
|
// The merged message subcommand must contain all four leaves.
|
|
want := map[string]bool{"send": false, "list": false, "send-by-bot": false, "send-by-webhook": false}
|
|
for _, leaf := range messageCmds[0].Commands() {
|
|
if _, ok := want[leaf.Name()]; ok {
|
|
want[leaf.Name()] = true
|
|
}
|
|
}
|
|
for leaf, seen := range want {
|
|
if !seen {
|
|
t.Errorf("expected 'chat message %s' after merge, missing", leaf)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_ParentMergeRecursive covers a multi-level merge:
|
|
// chat already has `group.members` (with add/remove), and a separate
|
|
// bot-group server contributes `dws chat group members add-bot` via
|
|
// command=group + parent=chat + groups.members. The "group" and "members"
|
|
// nodes must each be merged, not duplicated.
|
|
func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "group-chat",
|
|
Command: "chat",
|
|
Groups: map[string]market.CLIGroupDef{
|
|
"group": {Description: "群组管理"},
|
|
"group.members": {Description: "群成员管理"},
|
|
},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"add_group_member": {CLIName: "add", Group: "group.members"},
|
|
"remove_group_member": {CLIName: "remove", Group: "group.members"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Endpoint: "https://endpoint-bot",
|
|
CLI: market.CLIOverlay{
|
|
ID: "bot-group",
|
|
Command: "group",
|
|
Parent: "chat",
|
|
Groups: map[string]market.CLIGroupDef{
|
|
"members": {Description: "机器人群成员"},
|
|
},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"add_robot_to_group": {CLIName: "add-bot", Group: "members"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
if len(cmds) != 1 || cmds[0].Name() != "chat" {
|
|
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
|
|
}
|
|
|
|
// One 'group' under chat.
|
|
var groupCmds []*cobra.Command
|
|
for _, sub := range cmds[0].Commands() {
|
|
if sub.Name() == "group" {
|
|
groupCmds = append(groupCmds, sub)
|
|
}
|
|
}
|
|
if len(groupCmds) != 1 {
|
|
t.Fatalf("expected single 'group' under chat, got %d", len(groupCmds))
|
|
}
|
|
|
|
// One 'members' under chat.group.
|
|
var membersCmds []*cobra.Command
|
|
for _, sub := range groupCmds[0].Commands() {
|
|
if sub.Name() == "members" {
|
|
membersCmds = append(membersCmds, sub)
|
|
}
|
|
}
|
|
if len(membersCmds) != 1 {
|
|
t.Fatalf("expected single 'members' under chat.group, got %d", len(membersCmds))
|
|
}
|
|
|
|
// The merged members subcommand must contain add, remove, add-bot.
|
|
want := map[string]bool{"add": false, "remove": false, "add-bot": false}
|
|
for _, leaf := range membersCmds[0].Commands() {
|
|
if _, ok := want[leaf.Name()]; ok {
|
|
want[leaf.Name()] = true
|
|
}
|
|
}
|
|
for leaf, seen := range want {
|
|
if !seen {
|
|
t.Errorf("expected 'chat group members %s', missing", leaf)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_ParentMergeLeafCollision verifies that when two
|
|
// servers both produce the same leaf path (e.g. both try to register
|
|
// `chat message send`), the first one wins and the second is silently
|
|
// dropped rather than producing a duplicate cobra command.
|
|
func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-chat",
|
|
CLI: market.CLIOverlay{
|
|
ID: "group-chat",
|
|
Command: "chat",
|
|
Groups: map[string]market.CLIGroupDef{"message": {Description: "消息"}},
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"send_message_as_user": {CLIName: "send", Group: "message"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Endpoint: "https://endpoint-bot",
|
|
CLI: market.CLIOverlay{
|
|
ID: "bot-message",
|
|
Command: "message",
|
|
Parent: "chat",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
// Intentional collision: same leaf name.
|
|
"send_robot_group_message": {CLIName: "send"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
|
if len(cmds) != 1 {
|
|
t.Fatalf("expected 1 top-level, got %d", len(cmds))
|
|
}
|
|
messageCmd := findSubcommand(cmds[0], "message")
|
|
if messageCmd == nil {
|
|
t.Fatal("expected 'message' subcommand under chat")
|
|
}
|
|
// Exactly one 'send' leaf.
|
|
var sendCount int
|
|
for _, leaf := range messageCmd.Commands() {
|
|
if leaf.Name() == "send" {
|
|
sendCount++
|
|
}
|
|
}
|
|
if sendCount != 1 {
|
|
t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount)
|
|
}
|
|
}
|
|
|
|
// TestBuildFlagsFromDetailSchema_FormatEnumAnnotations verifies that the
|
|
// JSON Schema "format" and "enum" hints are copied onto the cobra flag's
|
|
// pflag annotations under x-cli-format / x-cli-enum, so PreParse
|
|
// handlers can use them when deciding whether to split glued tokens.
|
|
func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-calendar",
|
|
CLI: market.CLIOverlay{
|
|
ID: "calendar",
|
|
Command: "calendar",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"event_list": {CLIName: "list"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
details := map[string][]market.DetailTool{
|
|
"calendar": {
|
|
{
|
|
ToolName: "event_list",
|
|
ToolRequest: `{"properties":{` +
|
|
`"start":{"type":"string","format":"date-time","description":"开始时间"},` +
|
|
`"end":{"type":"string","format":"date-time","description":"结束时间"},` +
|
|
`"status":{"type":"string","enum":["confirmed","tentative","cancelled"]}` +
|
|
`}}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
|
list := findChild(cmds[0], "list")
|
|
if list == nil {
|
|
t.Fatal("list leaf not found")
|
|
}
|
|
|
|
startFlag := list.Flags().Lookup("start")
|
|
if startFlag == nil {
|
|
t.Fatal("--start flag missing")
|
|
}
|
|
if got := startFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
|
t.Errorf("--start x-cli-format = %v, want [date-time]", got)
|
|
}
|
|
|
|
endFlag := list.Flags().Lookup("end")
|
|
if endFlag == nil {
|
|
t.Fatal("--end flag missing")
|
|
}
|
|
if got := endFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
|
t.Errorf("--end x-cli-format = %v, want [date-time]", got)
|
|
}
|
|
|
|
statusFlag := list.Flags().Lookup("status")
|
|
if statusFlag == nil {
|
|
t.Fatal("--status flag missing")
|
|
}
|
|
gotEnum := statusFlag.Annotations["x-cli-enum"]
|
|
wantEnum := []string{"confirmed", "tentative", "cancelled"}
|
|
if !equalStringSlice(gotEnum, wantEnum) {
|
|
t.Errorf("--status x-cli-enum = %v, want %v", gotEnum, wantEnum)
|
|
}
|
|
// Status has no format and should not carry x-cli-format.
|
|
if got := statusFlag.Annotations["x-cli-format"]; len(got) != 0 {
|
|
t.Errorf("--status should not have x-cli-format, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MapsTo_WithoutTransform verifies that a flag
|
|
// carrying only MapsTo (no transform) moves its literal value to the
|
|
// target MCP parameter slot and drops the source key. The canonical use
|
|
// case is exposing --content as a sibling of --markdown that both feed
|
|
// the same upstream `markdown` parameter.
|
|
func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-doc",
|
|
CLI: market.CLIOverlay{
|
|
ID: "doc",
|
|
Command: "doc",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"update_document": {
|
|
CLIName: "update",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"nodeId": {Alias: "node"},
|
|
"content": {Alias: "content", MapsTo: "markdown"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
if runner.lastParams["markdown"] != "# 标题" {
|
|
t.Errorf("params[markdown] = %v, want '# 标题'", runner.lastParams["markdown"])
|
|
}
|
|
if _, leftover := runner.lastParams["content"]; leftover {
|
|
t.Errorf("source key 'content' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MapsTo_WithFileReadTransform verifies the full
|
|
// envelope shape that #277 needs: a path-typed flag (--content-file) that
|
|
// reads the file via the file_read transform AND routes the resulting
|
|
// string into a sibling MCP parameter (markdown). End-to-end: user types
|
|
// a path, the upstream tool receives file contents under the right key.
|
|
func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "note.md")
|
|
contents := "# 项目周报\n\n- 完成 A\n- 完成 B\n"
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-doc",
|
|
CLI: market.CLIOverlay{
|
|
ID: "doc",
|
|
Command: "doc",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"update_document": {
|
|
CLIName: "update",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"nodeId": {Alias: "node"},
|
|
"contentFile": {
|
|
Alias: "content-file",
|
|
MapsTo: "markdown",
|
|
Transform: "file_read",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
if runner.lastParams["markdown"] != contents {
|
|
t.Errorf("params[markdown] = %v, want file contents", runner.lastParams["markdown"])
|
|
}
|
|
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
|
t.Errorf("source key 'contentFile' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne verifies the
|
|
// realistic pre-prod shape: two sibling flags (--content literal and
|
|
// --content-file path) both mapsTo "markdown", guarded by the existing
|
|
// tool-level cobra MutuallyExclusive constraint. When the user sets only
|
|
// one, it routes through cleanly; the other source key is absent.
|
|
func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-doc",
|
|
CLI: market.CLIOverlay{
|
|
ID: "doc",
|
|
Command: "doc",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"update_document": {
|
|
CLIName: "update",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"nodeId": {Alias: "node"},
|
|
"content": {Alias: "content", MapsTo: "markdown"},
|
|
"contentFile": {
|
|
Alias: "content-file",
|
|
MapsTo: "markdown",
|
|
Transform: "file_read",
|
|
},
|
|
},
|
|
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
if err := cmds[0].Execute(); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
if runner.lastParams["markdown"] != "literal body" {
|
|
t.Errorf("params[markdown] = %v, want 'literal body'", runner.lastParams["markdown"])
|
|
}
|
|
if _, leftover := runner.lastParams["content"]; leftover {
|
|
t.Errorf("source key 'content' must be deleted, got params=%+v", runner.lastParams)
|
|
}
|
|
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
|
t.Errorf("untouched sibling key 'contentFile' must not appear, got params=%+v", runner.lastParams)
|
|
}
|
|
}
|
|
|
|
// TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra verifies that
|
|
// when both mapsTo siblings are set, the existing tool-level
|
|
// MutuallyExclusive constraint produces a cobra error before dispatch
|
|
// runs. This is a sanity regression check — the cobra mechanism is
|
|
// pre-existing, but combining it with mapsTo is the realistic envelope
|
|
// shape #277 needs.
|
|
func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &captureRunner{}
|
|
servers := []market.ServerDescriptor{
|
|
{
|
|
Endpoint: "https://endpoint-doc",
|
|
CLI: market.CLIOverlay{
|
|
ID: "doc",
|
|
Command: "doc",
|
|
ToolOverrides: map[string]market.CLIToolOverride{
|
|
"update_document": {
|
|
CLIName: "update",
|
|
Flags: map[string]market.CLIFlagOverride{
|
|
"nodeId": {Alias: "node"},
|
|
"content": {Alias: "content", MapsTo: "markdown"},
|
|
"contentFile": {Alias: "content-file", MapsTo: "markdown", Transform: "file_read"},
|
|
},
|
|
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cmds := BuildDynamicCommands(servers, runner, nil)
|
|
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
|
|
cmds[0].SilenceErrors = true
|
|
cmds[0].SilenceUsage = true
|
|
err := cmds[0].Execute()
|
|
if err == nil {
|
|
t.Fatal("expected mutually-exclusive error, got nil")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
|
|
t.Fatalf("expected mutually-exclusive error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// equalStringSlice is a small helper for slice comparison in tests.
|
|
func equalStringSlice(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|