// 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 != "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, 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, 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, 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, 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, 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, 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, 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, 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, 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 "-- 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, nil) 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, 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, 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, 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, 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 }