feat: declarative LeafSpec command framework + schema generation/consumption separation

== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.

== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.

== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
  flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.

== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.

== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.

== AGENTS.md ==
Documents the generation/consumption split.

Verified: make policy exit 0, drift zero, all tests pass.
This commit is contained in:
玉澜 committed 2026-07-19 09:38:43 +08:00
1 parent e69a1084a7
commit 51dc237d8a
83 files changed
+447387 -444470

No files matched your search

+14 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
@@ -62,6 +63,12 @@ func installUnknownVerbFallback(group *cobra.Command) {
// Cobra intercepts --help before RunE, so without this the fallback
// would never fire when --help is present.
origHelp := group.HelpFunc()
// captureBaseHelpFunc stores the unwrapped cobra default help renderer,
// captured once at install time (before configureRootHelp wraps root's
// HelpFunc). This avoids infinite recursion when calendar commands are
// tested in isolation (calendar IS the Root in test trees, so
// cmd.Root().HelpFunc() would return calendar's own wrapper → loop).
captureBaseHelpFunc := origHelp
group.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == group {
// HelpFunc receives os.Args[1:] (full arg slice without binary).
@@ -73,8 +80,14 @@ func installUnknownVerbFallback(group *cobra.Command) {
printUnknownSubcmdError(cmd, bad)
return
}
origHelp(cmd, args)
return
}
origHelp(cmd, args)
// For non-group commands, call the base (unwrapped) help renderer,
// then apply safety annotation. We do NOT call cmd.Root().HelpFunc()
// because in test trees calendar IS the root — that would recurse.
captureBaseHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
})
prev := group.RunE
+579 -611
View File
File diff suppressed because it is too large. Load diff
+526
View File
@@ -0,0 +1,526 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// fakeDevAppRunner 捕获 runDevAppTool 派发的 invocation,用于断言 LeafSpec.Call
// 装配出的 toolArgs 与手写版逐字等价。这是 catalog drift 不覆盖的运行时维度。
type fakeDevAppRunner struct {
got executor.Invocation
}
func (f *fakeDevAppRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
f.got = inv
// 空 Response:normalizeDevAppServiceResult 无 "content" 时早返回,安全。
return executor.Result{Invocation: inv, Response: map[string]any{}}, nil
}
// TestDevAppCredentialsGetLeafDispatchesTrimmedArgs 验证迁移到 LeafSpec 后:
// toolArgs 键/值/trim 与手写版等价,且 PostMount 设上了 schema 注解与 NoArgs。
func TestDevAppCredentialsGetLeafDispatchesTrimmedArgs(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppCredentialsGetCommand(r)
// 含首尾空白:验证 LeafFlag.Trim 等价手写 devAppStringFlag 的 TrimSpace。
if err := cmd.Flags().Set("unified-app-id", " APP-123 "); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if r.got.Tool != devAppCredentialsGetTool {
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppCredentialsGetTool)
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-123" {
t.Fatalf("unifiedAppId = %q, want trimmed \"APP-123\"", v)
}
if cmd.Annotations["mcp-tool"] != devAppCredentialsGetTool {
t.Fatalf("mcp-tool annotation = %q (PostMount 未生效)", cmd.Annotations["mcp-tool"])
}
if cmd.Args == nil {
t.Fatal("Args == nil, want NoArgs via PostMount")
}
}
// TestDevAppLifecycleLeafWriteGuardAndArgs 验证写守卫拦截/放行 + toolArgs 装配。
func TestDevAppLifecycleLeafWriteGuardAndArgs(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppLifecycleCommand(r, "enable", "启用应用", devAppEnableTool)
if err := cmd.Flags().Set("unified-app-id", "APP-9"); err != nil {
t.Fatal(err)
}
// 无 --yes / --dry-run:写守卫必须拦下(devAppRequireWriteGuard)。
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("RunE() without --yes: want write-guard error, got nil")
}
// devAppYes 在 cmd.Flags 里找 "yes";注册并置位,守卫放行。
cmd.Flags().Bool("yes", false, "")
if err := cmd.Flags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() with --yes: error = %v", err)
}
if r.got.Tool != devAppEnableTool {
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppEnableTool)
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-9" {
t.Fatalf("unifiedAppId = %q, want \"APP-9\"", v)
}
}
// TestDevAppCredentialsGetMissingRequired 验证自定义必填报错(apperrors)逐字保留。
func TestDevAppCredentialsGetMissingRequired(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppCredentialsGetCommand(r)
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("want required error for missing --unified-app-id")
}
if !strings.Contains(err.Error(), "--unified-app-id 为必填") {
t.Fatalf("err = %v, want 含 \"--unified-app-id 为必填\"", err)
}
}
// TestDevAppVersionLeafs 验证 version get/status/check-approval 的 toolArgs
// 装配:双必填 trim + check-approval 经 Call 注入 precheckOnly=true 常量。
func TestDevAppVersionLeafs(t *testing.T) {
cases := []struct {
name string
build func(*fakeDevAppRunner) *cobra.Command
tool string
precheckOnly any
}{
{"get", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionGetCommand(r) }, devAppVersionDetailTool, nil},
{"status", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionStatusCommand(r) }, devAppVersionStatusTool, nil},
{"check-approval", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionCheckApprovalCommand(r) }, devAppVersionPublishTool, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := c.build(r)
if err := cmd.Flags().Set("unified-app-id", " APP-1 "); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("version-id", " v9 "); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if r.got.Tool != c.tool {
t.Fatalf("tool = %q, want %q", r.got.Tool, c.tool)
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-1" {
t.Fatalf("unifiedAppId = %q, want trimmed \"APP-1\"", v)
}
if v, _ := r.got.Params["versionId"].(string); v != "v9" {
t.Fatalf("versionId = %q, want trimmed \"v9\"", v)
}
if c.precheckOnly != nil {
if v, _ := r.got.Params["precheckOnly"].(bool); v != c.precheckOnly {
t.Fatalf("precheckOnly = %v, want %v", r.got.Params["precheckOnly"], c.precheckOnly)
}
} else if _, present := r.got.Params["precheckOnly"]; present {
t.Fatalf("precheckOnly present = %v, want absent", r.got.Params["precheckOnly"])
}
})
}
}
// TestDevAppMemberListLeaf 验证 member list 的 unified-id 装配。
func TestDevAppMemberListLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppMemberListCommand(r)
if err := cmd.Flags().Set("unified-app-id", "APP-7"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if r.got.Tool != devAppMemberListTool {
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppMemberListTool)
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-7" {
t.Fatalf("unifiedAppId = %q, want \"APP-7\"", v)
}
}
// TestDevAppPermissionListLeaf 验证 ToUpper transform + auth-status 默认 + 命令别名。
func TestDevAppPermissionListLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppPermissionListCommand(r)
// 命令别名 "search" 经 PostMount 设回。
if !containsString(cmd.Aliases, "search") {
t.Fatalf("cmd.Aliases = %v, want contain search", cmd.Aliases)
}
_ = cmd.Flags().Set("unified-app-id", "APP-P")
_ = cmd.Flags().Set("scope-type", "app")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
// auth-status 默认 ALL → ToUpper ALL。
if v, _ := r.got.Params["authStatus"].(string); v != "ALL" {
t.Fatalf("authStatus = %v, want ALL (default ToUpper)", r.got.Params["authStatus"])
}
// scope-type 小写 app → ToUpper APP。
if v, _ := r.got.Params["scopeType"].(string); v != "APP" {
t.Fatalf("scopeType = %v, want APP (ToUpper)", r.got.Params["scopeType"])
}
if _, present := r.got.Params["pageSize"]; !present {
t.Fatal("pageSize missing, want cursor injection")
}
}
// TestDevAppVersionPublishLeaf 验证 precheckOnly=false 常量 + confirmed-sensitive Changed() 语义。
func TestDevAppVersionPublishLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppVersionPublishCommand(r)
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
_ = cmd.Flags().Set("unified-app-id", "APP-V")
_ = cmd.Flags().Set("version-id", "v1")
// confirmed-sensitive 未显式设:不入参。
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["precheckOnly"].(bool); v != false {
t.Fatalf("precheckOnly = %v, want false", r.got.Params["precheckOnly"])
}
if _, present := r.got.Params["confirmedSensitive"]; present {
t.Fatalf("confirmedSensitive present = %v, want omitted (not Changed)", r.got.Params["confirmedSensitive"])
}
// 显式设 confirmed-sensitive=true:入参。
r2 := &fakeDevAppRunner{}
cmd2 := newDevAppVersionPublishCommand(r2)
cmd2.Flags().Bool("yes", false, "")
_ = cmd2.Flags().Set("yes", "true")
_ = cmd2.Flags().Set("unified-app-id", "APP-V")
_ = cmd2.Flags().Set("version-id", "v1")
_ = cmd2.Flags().Set("confirmed-sensitive", "true")
_ = cmd2.RunE(cmd2, nil)
if v, _ := r2.got.Params["confirmedSensitive"].(bool); v != true {
t.Fatalf("confirmedSensitive = %v, want true", r2.got.Params["confirmedSensitive"])
}
}
// TestDevAppGetLeafDualKey 验证二选一定位键。
func TestDevAppGetLeafDualKey(t *testing.T) {
// 都不传:报错。
r := &fakeDevAppRunner{}
cmd := newDevAppGetCommand(r)
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "请传入 --unified-app-id 或 --app-key") {
t.Fatalf("err = %v, want 二选一报错", err)
}
// 只 app-key:params 含 appKey,无 unifiedAppId。
_ = cmd.Flags().Set("app-key", "dingABC")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["appKey"].(string); v != "dingABC" {
t.Fatalf("appKey = %v, want dingABC", r.got.Params["appKey"])
}
if _, present := r.got.Params["unifiedAppId"]; present {
t.Fatalf("unifiedAppId present, want omitted")
}
}
// TestDevAppListLeaf 是 showcase:退役 devAppPutString/devAppPutInt/devAppFlagOrFallback。
// 验证 name/keyword 别名回退、LeafInt(!=0 入参)、cursor 注入、OmitEmpty。
func TestDevAppListLeaf(t *testing.T) {
// keyword 别名回退:只设 keyword,name 应取 keyword 值。
r := &fakeDevAppRunner{}
cmd := newDevAppListCommand(r)
_ = cmd.Flags().Set("keyword", "DemoApp")
_ = cmd.Flags().Set("app-group-id", "5")
_ = cmd.Flags().Set("develop-type", "0") // 0:LeafInt 不入参
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["name"].(string); v != "DemoApp" {
t.Fatalf("name = %q, want keyword-fallback \"DemoApp\"", v)
}
if v, _ := r.got.Params["appGroupId"].(int); v != 5 {
t.Fatalf("appGroupId = %v, want int(5)", r.got.Params["appGroupId"])
}
if _, present := r.got.Params["developType"]; present {
t.Fatalf("developType present = %v, want omitted (LeafInt 0)", r.got.Params["developType"])
}
if _, present := r.got.Params["pageSize"]; !present {
t.Fatal("pageSize missing, want cursor injection")
}
if _, present := r.got.Params["creator"]; present {
t.Fatalf("creator present, want omitted (OmitEmpty)")
}
// name 优先于 keyword。
r2 := &fakeDevAppRunner{}
cmd2 := newDevAppListCommand(r2)
_ = cmd2.Flags().Set("name", "Primary")
_ = cmd2.Flags().Set("keyword", "Fallback")
_ = cmd2.RunE(cmd2, nil)
if v, _ := r2.got.Params["name"].(string); v != "Primary" {
t.Fatalf("name = %q, want Primary (name beats keyword)", v)
}
}
// TestDevAppEventListLeaf 验证 cursor/pageSize 经 devAppApplyCursorParams 注入。
func TestDevAppEventListLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppEventListCommand(r)
_ = cmd.Flags().Set("unified-app-id", "APP-E")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-E" {
t.Fatalf("unifiedAppId = %q, want APP-E", v)
}
// page-size 默认 20,经 devAppApplyCursorParams 注入为 float64(20)。
if _, present := r.got.Params["pageSize"]; !present {
t.Fatal("pageSize missing, want injected by devAppApplyCursorParams")
}
// cursor 默认空:不入参。
if _, present := r.got.Params["cursor"]; present {
t.Fatalf("cursor present = %v, want omitted when empty", r.got.Params["cursor"])
}
// keyword 未设:OmitEmpty 省略。
if _, present := r.got.Params["keyword"]; present {
t.Fatalf("keyword present, want omitted (OmitEmpty)")
}
}
// TestDevAppUpdateLeaf 验证「至少一项」校验 + OmitEmpty 装配。
func TestDevAppUpdateLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppUpdateCommand(r)
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
_ = cmd.Flags().Set("unified-app-id", "APP-U")
// 全空:至少一项拦。
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "至少提供一项待更新字段") {
t.Fatalf("err = %v, want 至少提供一项待更新字段", err)
}
_ = cmd.Flags().Set("desc", " 新描述 ")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["desc"].(string); v != "新描述" {
t.Fatalf("desc = %q, want trimmed", v)
}
if _, present := r.got.Params["name"]; present {
t.Fatalf("name present, want omitted (OmitEmpty)")
}
}
// TestDevAppMemberAddLeaf 验证 userIds []string 注入 + memberType 装配 + 写守卫/必填顺序。
func TestDevAppMemberAddLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppMemberAddCommand(r)
_ = cmd.Flags().Set("unified-app-id", "APP-M")
// 无 --yes:写守卫拦。
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("want write-guard error without --yes")
}
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
// 缺 user-ids/member-type:必填拦。
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "--user-ids 为必填") {
t.Fatalf("err = %v, want --user-ids 为必填", err)
}
_ = cmd.Flags().Set("user-ids", "u1, u2")
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "--member-type 为必填") {
t.Fatalf("err = %v, want --member-type 为必填", err)
}
// 齐全:userIds []string + memberType 装配。
_ = cmd.Flags().Set("member-type", "DEVELOPER")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
users, ok := r.got.Params["userIds"].([]string)
if !ok || len(users) != 2 || users[0] != "u1" || users[1] != "u2" {
t.Fatalf("userIds = %v, want [u1 u2]", r.got.Params["userIds"])
}
if v, _ := r.got.Params["memberType"].(string); v != "DEVELOPER" {
t.Fatalf("memberType = %v, want DEVELOPER", r.got.Params["memberType"])
}
}
// TestDevAppSecurityConfigLeaf 验证 3 个列表 flag 的 Call 注入 + 「至少一项」校验。
func TestDevAppSecurityConfigLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppSecurityConfigCommand(r)
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
_ = cmd.Flags().Set("unified-app-id", "APP-S")
// 全空:至少一项拦。
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "至少提供一项安全配置") {
t.Fatalf("err = %v, want 至少提供一项安全配置", err)
}
// 设 ip-whitelist:注入 []string,其余省略。
_ = cmd.Flags().Set("ip-whitelist", "10.0.0.1; 10.0.0.2")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
ips, ok := r.got.Params["ipWhitelist"].([]string)
if !ok || len(ips) != 2 || ips[0] != "10.0.0.1" || ips[1] != "10.0.0.2" {
t.Fatalf("ipWhitelist = %v, want [10.0.0.1 10.0.0.2]", r.got.Params["ipWhitelist"])
}
if _, present := r.got.Params["redirectUrls"]; present {
t.Fatalf("redirectUrls present, want omitted")
}
}
// TestDevAppPermissionAddLeaf 验证 scope-values 经 Call 注入为 []string + 写守卫/必填。
func TestDevAppPermissionAddLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppPermissionAddCommand(r)
// scope-values 在 PostMount 注册。
if err := cmd.Flags().Set("unified-app-id", "APP-3"); err != nil {
t.Fatal(err)
}
// 无 --yes 且无 scope-values:写守卫先拦。
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("want write-guard error without --yes")
}
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
// 有 --yes 但无 scope-values:必填校验拦。
if err := cmd.RunE(cmd, nil); err == nil ||
!strings.Contains(err.Error(), "--scope-values 为必填") {
t.Fatalf("err = %v, want --scope-values 为必填", err)
}
// 齐全:scopeValues 注入为 []string。
_ = cmd.Flags().Set("scope-values", "Contact.User.mobile, qyapi_robot_sendmsg")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
scopes, ok := r.got.Params["scopeValues"].([]string)
if !ok || len(scopes) != 2 || scopes[0] != "Contact.User.mobile" || scopes[1] != "qyapi_robot_sendmsg" {
t.Fatalf("scopeValues = %v, want [Contact.User.mobile qyapi_robot_sendmsg]", r.got.Params["scopeValues"])
}
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-3" {
t.Fatalf("unifiedAppId = %q, want APP-3", v)
}
}
// TestDevAppWebappConfigLeaf 验证 OmitEmpty 装配 + 「至少一项」跨 flag 校验。
func TestDevAppWebappConfigLeaf(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := newDevAppWebappConfigCommand(r)
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
// 全空:写守卫放行后,「至少一项」必须拦。
if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil {
t.Fatal(err)
}
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "至少提供一项网页应用配置") {
t.Fatalf("err = %v, want 至少提供一项网页应用配置", err)
}
// 只设一项:该项入参,其余 OmitEmpty 省略。
if err := cmd.Flags().Set("homepage-url", " https://a.example.invalid "); err != nil {
t.Fatal(err)
}
r = &fakeDevAppRunner{}
cmd2 := newDevAppWebappConfigCommand(r)
cmd2.Flags().Bool("yes", false, "")
_ = cmd2.Flags().Set("yes", "true")
_ = cmd2.Flags().Set("unified-app-id", "APP-1")
_ = cmd2.Flags().Set("homepage-url", " https://a.example.invalid ")
if err := cmd2.RunE(cmd2, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if v, _ := r.got.Params["homepageUrl"].(string); v != "https://a.example.invalid" {
t.Fatalf("homepageUrl = %q, want trimmed", v)
}
if _, present := r.got.Params["h5PageType"]; present {
t.Fatalf("h5PageType present = %v, want omitted (OmitEmpty)", r.got.Params["h5PageType"])
}
if _, present := r.got.Params["ompUrl"]; present {
t.Fatalf("ompUrl present = %v, want omitted (OmitEmpty)", r.got.Params["ompUrl"])
}
}
// TestDevAppLeafToolArgsTable 表驱动补齐剩余迁移命令的 toolArgs 断言。
// 覆盖此前无断言的:robot enable/disable、permission remove、event sub/unsub、
// member remove、version create、app create、robot config-get、lifecycle disable。
func TestDevAppLeafToolArgsTable(t *testing.T) {
cases := []struct {
name string
build func(executor.Runner) *cobra.Command
flags map[string]string
needsYes bool
wantTool string
wantParam map[string]any
wantAbsent []string // 应缺席的 params 键(OmitEmpty 校验)
}{
{"robot enable", newDevAppRobotEnableCommand, map[string]string{"unified-app-id": "APP-R"}, true, devAppRobotEnableTool, map[string]any{"unifiedAppId": "APP-R"}, nil},
{"robot disable", newDevAppRobotOfflineCommand, map[string]string{"unified-app-id": "APP-R"}, true, devAppRobotOfflineTool, map[string]any{"unifiedAppId": "APP-R"}, nil},
{"permission remove", newDevAppPermissionRemoveCommand, map[string]string{"unified-app-id": "APP-P", "scope-values": "a.b, c.d"}, true, devAppPermissionRmTool, map[string]any{"unifiedAppId": "APP-P", "scopeValues": []string{"a.b", "c.d"}}, nil},
{"event subscribe", newDevAppEventSubscribeCommand, map[string]string{"unified-app-id": "APP-E", "event-codes": "x, y"}, true, devAppEventSubscribeTool, map[string]any{"unifiedAppId": "APP-E", "eventCodes": []string{"x", "y"}}, nil},
{"event unsubscribe", newDevAppEventUnsubscribeCommand, map[string]string{"unified-app-id": "APP-E", "event-codes": "x, y"}, true, devAppEventUnsubscribeTool, map[string]any{"unifiedAppId": "APP-E", "eventCodes": []string{"x", "y"}}, nil},
{"member remove", newDevAppMemberRemoveCommand, map[string]string{"unified-app-id": "APP-M", "user-ids": "u1,u2", "member-type": "DEVELOPER"}, true, devAppMemberRemoveTool, map[string]any{"unifiedAppId": "APP-M", "userIds": []string{"u1", "u2"}, "memberType": "DEVELOPER"}, nil},
{"version create", newDevAppVersionCreateCommand, map[string]string{"unified-app-id": "APP-V", "desc": "新增机器人"}, true, devAppVersionCreateTool, map[string]any{"unifiedAppId": "APP-V", "desc": "新增机器人"}, []string{"version"}},
{"app create", newDevAppCreateCommand, map[string]string{"name": "DemoApp"}, true, devAppCreateTool, map[string]any{"name": "DemoApp"}, []string{"desc", "iconMediaId"}},
{"robot config get", newDevAppRobotConfigGetCommand, map[string]string{"unified-app-id": "APP-C"}, false, devAppRobotConfigGetTool, map[string]any{"unifiedAppId": "APP-C"}, nil},
{"lifecycle disable", func(r executor.Runner) *cobra.Command {
return newDevAppLifecycleCommand(r, "disable", "停用应用", devAppDisableTool)
}, map[string]string{"unified-app-id": "APP-D"}, true, devAppDisableTool, map[string]any{"unifiedAppId": "APP-D"}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := &fakeDevAppRunner{}
cmd := c.build(r)
if c.needsYes {
cmd.Flags().Bool("yes", false, "")
_ = cmd.Flags().Set("yes", "true")
}
for k, v := range c.flags {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if r.got.Tool != c.wantTool {
t.Fatalf("tool = %q, want %q", r.got.Tool, c.wantTool)
}
for k, want := range c.wantParam {
if got := r.got.Params[k]; !reflect.DeepEqual(got, want) {
t.Fatalf("param %s = %#v (%T), want %#v (%T)", k, got, got, want, want)
}
}
for _, k := range c.wantAbsent {
if _, present := r.got.Params[k]; present {
t.Fatalf("param %s present = %#v, want absent (OmitEmpty)", k, r.got.Params[k])
}
}
})
}
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// leaf.go 是叶子命令的统一构建框架。
//
// 现状问题:每个叶子命令手写 cobra.Command,required 校验、别名回退、环境
// 变量回退、值转换、参数装配、派发调用在各个产品文件里各写一份,行为难以
// 保持一致。LeafSpec 把这些共性收敛为声明式结构:命令只声明「flag 集合 +
// 绑定关系」,框架统一执行。默认派发走 MCP 直连(callMCPTool);非 MCP
// 命令(如 devapp 走 executor.Runner)通过 LeafSpec.Call 注入自己的派发器,
// 复用同一套 flag/校验/装配逻辑。复杂命令可通过 LeafSpec.RunE 完全自定义
// (逃生舱),不在框架适用范围内强行套用。
//
// 迁移纪律:从手写命令迁移到 LeafSpec 时,flag 名、默认值、usage 文案、
// MarkFlagRequired、required 错误格式、toolArgs 键与值必须逐字保持一致,
// 由 check-generated-drift(catalog 零漂移)与命令兼容性检查兜底证明等价。
// LeafFlagKind 是 flag 的值类型。
type LeafFlagKind int
const (
// LeafString 字符串 flag(默认)。
LeafString LeafFlagKind = iota
// LeafInt64 整型 flag;仅在值 > 0 时进入 toolArgs(分页游标语义)。
LeafInt64
// LeafInt 整型 flag(注册为 cobra Int);仅在值 != 0 时进入 toolArgs,
// 对应手写「putInt 仅在非零才入参」语义(如 devapp app-group-id)。
LeafInt
)
// LeafFlag 声明一个 flag 的注册方式与到 MCP toolArgs 的绑定。
type LeafFlag struct {
Name string // flag 名(kebab-case)
Usage string // 注册 usage 文案
Kind LeafFlagKind // 值类型,默认 LeafString
Default string // 注册默认值(仅 LeafString 有效)
// Required 为 true 时在 RunE 期校验有效值非空。普通 Required 汇聚为
// cmdutil.ValidateRequiredFlags 兼容的统一报错;配置 EnvVar 时回退读
// 环境变量,仍为空则报 RequiredHint(或默认文案)。
Required bool
RequiredHint string
// MarkRequired 为 true 时调用 cobra MarkFlagRequired(catalog required
// 投影的硬下限),cobra 会在 RunE 之前先行报错。
MarkRequired bool
Aliases []string // 隐藏别名,主 flag 为空时按序回退(字符串)
EnvVar string // 有效值为空时回退读取的环境变量
// ArgDefault:注册默认值为空、但 toolArgs 需要兜底的场景(如 list type
// 注册默认 "ALL" 之外的旧命令);有效值为空时以 ArgDefault 入参。
ArgDefault string
// Bind 是 toolArgs 的键;为空时使用 Name。
Bind string
// Transform 把字符串有效值转为入参值;nil 时原样入参。返回
// (nil, nil) 表示跳过该键(用于「可空数值:为空或解析失败都不入参」
// 的手写语义)。
Transform func(raw string) (any, error)
// OmitEmpty 为 true 时有效值为空则不进入 toolArgs(LeafInt64 恒为
// 「值 > 0 才入参」,忽略此字段)。
OmitEmpty bool
// Trim 为 true 时对有效值做 strings.TrimSpace(主 flag/别名/env 统一),
// 对应手写 devAppStringFlag 恒 trim 的语义;亦使「纯空白」值在 required
// 校验中视为空。
Trim bool
}
// LeafSpec 声明一个 MCP 直连叶子命令。
type LeafSpec struct {
Use string
Short string
Long string
Example string
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。
Server string
Tool string
Flags []LeafFlag
// Call 是可插拔派发函数,非空时替代默认的 callMCPTool/callMCPToolOnServer。
// 供非 MCP 直连命令(如 devapp 走 executor.Runner)复用本框架:调用方用
// 闭包捕获自己的 runner/派发器即可。签名与默认路径一致——收到框架装配好
// 的 toolArgs,自行派发。
Call func(cmd *cobra.Command, tool string, args map[string]any) error
// Validate 是跨 flag 校验钩子(如时间区间、互斥关系),在 required
// 校验之后、toolArgs 装配之前执行;nil 时跳过。单 flag 的格式转换
// 应放在 LeafFlag.Transform,不要放进 Validate。
Validate func(cmd *cobra.Command, args []string) error
// RunE 非空时完全自定义执行体(逃生舱),框架只负责注册 flag。
RunE func(cmd *cobra.Command, args []string) error
// PostMount 在 flag 注册完成之后、RunE 设定之前对构建好的 cmd 做最终
// 调整(设置 Args/DisableAutoGenTag、调用 annotate/preferLegacy 等)。
// 无论是否使用 RunE 逃生舱都会执行。对标 lark shortcut 的 PostMount。
PostMount func(cmd *cobra.Command)
}
// NewLeafCommand 按 LeafSpec 构建叶子命令。
func NewLeafCommand(spec LeafSpec) *cobra.Command {
cmd := &cobra.Command{
Use: spec.Use,
Short: spec.Short,
Long: spec.Long,
Example: spec.Example,
}
for _, flag := range spec.Flags {
switch flag.Kind {
case LeafInt64:
cmd.Flags().Int64(flag.Name, 0, flag.Usage)
case LeafInt:
cmd.Flags().Int(flag.Name, 0, flag.Usage)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Usage)
}
for _, alias := range flag.Aliases {
cmd.Flags().String(alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
}
}
if spec.PostMount != nil {
spec.PostMount(cmd)
}
if spec.RunE != nil {
cmd.RunE = spec.RunE
return cmd
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if err := leafValidateRequired(cmd, spec); err != nil {
return err
}
if spec.Validate != nil {
if err := spec.Validate(cmd, args); err != nil {
return err
}
}
toolArgs, err := leafArgs(cmd, spec)
if err != nil {
return err
}
if spec.Call != nil {
return spec.Call(cmd, spec.Tool, toolArgs)
}
if spec.Server != "" {
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
}
return callMCPTool(spec.Tool, toolArgs)
}
return cmd
}
// leafValidateRequired 复现手写命令的 required 语义:普通 Required 统一报
// 「missing required flag(s)」;带 EnvVar 的 Required 单独报 RequiredHint。
// 普通组先于环境变量组校验,保持与手写顺序一致。
func leafValidateRequired(cmd *cobra.Command, spec LeafSpec) error {
var plain []string
for _, flag := range spec.Flags {
if flag.Required && flag.EnvVar == "" && flag.RequiredHint == "" {
plain = append(plain, flag.Name)
}
}
if err := validateRequiredFlags(cmd, plain...); err != nil {
return err
}
for _, flag := range spec.Flags {
if !flag.Required || (flag.EnvVar == "" && flag.RequiredHint == "") {
continue
}
if leafEffectiveValue(cmd, flag) == "" {
hint := flag.RequiredHint
if hint == "" {
hint = fmt.Sprintf("flag --%s is required", flag.Name)
}
return fmt.Errorf("%s", hint)
}
}
return nil
}
// leafArgs 按绑定关系装配 toolArgs。
func leafArgs(cmd *cobra.Command, spec LeafSpec) (map[string]any, error) {
toolArgs := map[string]any{}
for _, flag := range spec.Flags {
bind := flag.Bind
if bind == "" {
bind = flag.Name
}
if flag.Kind == LeafInt64 {
if v, _ := cmd.Flags().GetInt64(flag.Name); v > 0 {
toolArgs[bind] = v
}
continue
}
if flag.Kind == LeafInt {
if v, _ := cmd.Flags().GetInt(flag.Name); v != 0 {
toolArgs[bind] = v
}
continue
}
effective := leafEffectiveValue(cmd, flag)
if effective == "" && flag.ArgDefault != "" {
effective = flag.ArgDefault
}
if effective == "" && flag.OmitEmpty {
continue
}
if flag.Transform != nil {
value, err := flag.Transform(effective)
if err != nil {
return nil, err
}
if value == nil {
continue
}
toolArgs[bind] = value
continue
}
toolArgs[bind] = effective
}
return toolArgs, nil
}
// leafEffectiveValue 按「主 flag → 别名 → 环境变量」顺序取有效值;Trim 为
// true 时对结果统一 TrimSpace。
func leafEffectiveValue(cmd *cobra.Command, flag LeafFlag) string {
v := leafRawValue(cmd, flag)
if flag.Trim {
v = strings.TrimSpace(v)
}
return v
}
// leafRawValue 取未 trim 的原始有效值(主 flag → 别名 → 环境变量)。
func leafRawValue(cmd *cobra.Command, flag LeafFlag) string {
if v := mustGetFlag(cmd, flag.Name); v != "" {
return v
}
for _, alias := range flag.Aliases {
if v, _ := cmd.Flags().GetString(alias); v != "" {
return v
}
}
if flag.EnvVar != "" {
return os.Getenv(flag.EnvVar)
}
return ""
}
+357
View File
@@ -0,0 +1,357 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
)
func leafTestSpec() LeafSpec {
return LeafSpec{
Use: "send",
Short: "发送",
Tool: "send_thing",
Flags: []LeafFlag{
{Name: "token", Usage: "令牌", Required: true, EnvVar: "DWS_LEAF_TEST_TOKEN", RequiredHint: "flag --token is required (or set DWS_LEAF_TEST_TOKEN)", Bind: "accessToken"},
{Name: "users", Usage: "用户列表", Required: true, Bind: "userList", Transform: leafTestCSV},
{Name: "content", Usage: "内容", Required: true},
{Name: "type", Usage: "类型", Default: "app", Bind: "remindType"},
{Name: "note", Usage: "备注", Aliases: []string{"remark"}, OmitEmpty: true, Bind: "noteText"},
{Name: "cursor", Usage: "游标", Kind: LeafInt64, Bind: "cursor"},
{Name: "scope", Usage: "范围", ArgDefault: "ALL", Bind: "scope"},
},
}
}
// leafTestCSV 把逗号分隔字符串拆成 []string(测试专用,避免依赖产品文件的 helper)。
func leafTestCSV(raw string) (any, error) {
if raw == "" {
return []string{}, nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out, nil
}
func TestNewLeafCommandRegistersFlags(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
f := cmd.Flags().Lookup("type")
if f == nil || f.DefValue != "app" || f.Usage != "类型" {
t.Fatalf("type flag = %+v, want default app with usage", f)
}
alias := cmd.Flags().Lookup("remark")
if alias == nil || !alias.Hidden {
t.Fatalf("alias remark = %+v, want registered and hidden", alias)
}
if cmd.Flags().Lookup("cursor") == nil {
t.Fatal("cursor flag not registered")
}
}
func TestNewLeafCommandMarksRequired(t *testing.T) {
cmd := NewLeafCommand(LeafSpec{
Use: "get", Tool: "get_thing",
Flags: []LeafFlag{{Name: "id", Usage: "ID", Required: true, MarkRequired: true}},
})
f := cmd.Flags().Lookup("id")
if f == nil || len(f.Annotations) == 0 {
t.Fatalf("id flag = %+v, want MarkFlagRequired annotations", f)
}
}
func TestLeafValidateRequiredPlainGroup(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
err := leafValidateRequired(cmd, leafTestSpec())
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
t.Fatalf("leafValidateRequired() = %v, want missing --content", err)
}
}
func TestLeafValidateRequiredEnvFallback(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
err := leafValidateRequired(cmd, leafTestSpec())
// 普通组(users/content)先报错,不触及 env 组。
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --users, --content") {
t.Fatalf("leafValidateRequired() = %v, want plain group first", err)
}
// 普通组满足后,env 缺失时走 RequiredHint。
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "c"); err != nil {
t.Fatal(err)
}
err = leafValidateRequired(cmd, leafTestSpec())
if err == nil || !strings.Contains(err.Error(), "DWS_LEAF_TEST_TOKEN") {
t.Fatalf("leafValidateRequired() = %v, want env hint", err)
}
// env 提供后通过。
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := leafValidateRequired(cmd, leafTestSpec()); err != nil {
t.Fatalf("leafValidateRequired() = %v, want nil", err)
}
}
func TestLeafArgs(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1, u2"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("remark", "via-alias"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("cursor", "10"); err != nil {
t.Fatal(err)
}
args, err := leafArgs(cmd, leafTestSpec())
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
if args["accessToken"] != "tok" {
t.Fatalf("accessToken = %v, want env fallback tok", args["accessToken"])
}
users, ok := args["userList"].([]string)
if !ok || len(users) != 2 || users[0] != "u1" || users[1] != "u2" {
t.Fatalf("userList = %v, want [u1 u2]", args["userList"])
}
if args["content"] != "hello" || args["remindType"] != "app" {
t.Fatalf("content/remindType = %v/%v", args["content"], args["remindType"])
}
if args["noteText"] != "via-alias" {
t.Fatalf("noteText = %v, want alias fallback", args["noteText"])
}
if args["cursor"] != int64(10) {
t.Fatalf("cursor = %v (%T), want int64(10)", args["cursor"], args["cursor"])
}
if args["scope"] != "ALL" {
t.Fatalf("scope = %v, want ArgDefault ALL", args["scope"])
}
}
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
args, err := leafArgs(cmd, leafTestSpec())
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
if _, present := args["noteText"]; present {
t.Fatalf("noteText present = %v, want omitted when empty", args["noteText"])
}
if _, present := args["cursor"]; present {
t.Fatalf("cursor present = %v, want omitted when <= 0", args["cursor"])
}
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
// leafArgs 之前执行,保证真实路径不会发出空值)。
if v, present := args["accessToken"]; !present || v != "" {
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
}
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
if v, present := args["remindType"]; !present || v != "app" {
t.Fatalf("remindType = %v/%v, want registered default", v, present)
}
}
func TestNewLeafCommandCustomRunE(t *testing.T) {
called := false
spec := leafTestSpec()
spec.RunE = func(cmd *cobra.Command, args []string) error {
called = true
return nil
}
cmd := NewLeafCommand(spec)
if err := cmd.RunE(cmd, nil); err != nil || !called {
t.Fatalf("custom RunE not used: called=%v err=%v", called, err)
}
}
func TestLeafValidateHookRunsAfterRequired(t *testing.T) {
validated := false
spec := LeafSpec{
Use: "list", Tool: "list_thing",
Flags: []LeafFlag{{Name: "start", Usage: "开始", Required: true}},
Validate: func(cmd *cobra.Command, args []string) error {
validated = true
return fmt.Errorf("range invalid")
},
}
cmd := NewLeafCommand(spec)
// required 未满足时先报 required,不触发 Validate。
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --start") {
t.Fatalf("RunE() = %v, want required error first", err)
}
if validated {
t.Fatal("Validate ran before required check passed")
}
// required 满足后 Validate 拦截。
if err := cmd.Flags().Set("start", "s"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err == nil || err.Error() != "range invalid" {
t.Fatalf("RunE() = %v, want Validate error", err)
}
if !validated {
t.Fatal("Validate did not run")
}
}
func TestLeafArgsTransformNilSkipsKey(t *testing.T) {
spec := LeafSpec{
Use: "list", Tool: "list_thing",
Flags: []LeafFlag{
{Name: "page", Usage: "页码", OmitEmpty: true, Bind: "pageNum", Transform: func(raw string) (any, error) {
return nil, nil // 解析失败语义:跳过
}},
},
}
cmd := NewLeafCommand(spec)
if err := cmd.Flags().Set("page", "abc"); err != nil {
t.Fatal(err)
}
args, err := leafArgs(cmd, spec)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
if _, present := args["pageNum"]; present {
t.Fatalf("pageNum present = %v, want skipped on (nil, nil) transform", args["pageNum"])
}
}
func TestLeafArgsLeafIntOmitsZero(t *testing.T) {
spec := LeafSpec{
Use: "list", Tool: "list_thing",
Flags: []LeafFlag{
{Name: "app-group-id", Usage: "分组", Kind: LeafInt, Bind: "appGroupId"},
{Name: "develop-type", Usage: "类型", Kind: LeafInt, Bind: "developType"},
},
}
cmd := NewLeafCommand(spec)
// 默认 0:不入参。
args, err := leafArgs(cmd, spec)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
if _, present := args["appGroupId"]; present {
t.Fatalf("appGroupId present = %v, want omitted when 0", args["appGroupId"])
}
// 非 0 入参,类型为 int(与手写 devAppPutInt 一致)。
if err := cmd.Flags().Set("develop-type", "2"); err != nil {
t.Fatal(err)
}
args, err = leafArgs(cmd, spec)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
v, ok := args["developType"].(int)
if !ok || v != 2 {
t.Fatalf("developType = %v (%T), want int(2)", args["developType"], args["developType"])
}
}
func TestLeafCommandCallDispatch(t *testing.T) {
// Call 非空时替代默认 callMCPTool,收到框架装配好的 toolArgs。
var gotTool string
var gotArgs map[string]any
spec := LeafSpec{
Use: "list", Tool: "list_thing",
Flags: []LeafFlag{{Name: "name", Usage: "名称", Bind: "name"}},
Call: func(cmd *cobra.Command, tool string, args map[string]any) error {
gotTool, gotArgs = tool, args
return nil
},
}
cmd := NewLeafCommand(spec)
if err := cmd.Flags().Set("name", "demo"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() error = %v", err)
}
if gotTool != "list_thing" {
t.Fatalf("Call tool = %q, want list_thing", gotTool)
}
if gotArgs["name"] != "demo" {
t.Fatalf("Call args = %v, want name=demo", gotArgs)
}
}
func TestLeafPostMountApplied(t *testing.T) {
// PostMount 在 flag 注册后执行,用于设置 Args/annotation 等。
spec := LeafSpec{
Use: "get", Tool: "get_thing",
Flags: []LeafFlag{{Name: "id", Usage: "ID"}},
PostMount: func(cmd *cobra.Command) {
cmd.DisableAutoGenTag = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations["mcp-tool"] = "get_thing"
},
}
cmd := NewLeafCommand(spec)
if !cmd.DisableAutoGenTag {
t.Fatal("PostMount did not set DisableAutoGenTag")
}
if cmd.Annotations["mcp-tool"] != "get_thing" {
t.Fatalf("annotations = %v, want mcp-tool=get_thing", cmd.Annotations)
}
if cmd.Flags().Lookup("id") == nil {
t.Fatal("flag id not registered before PostMount")
}
}
func TestLeafArgsTrimsValue(t *testing.T) {
spec := LeafSpec{
Use: "get", Tool: "get_thing",
Flags: []LeafFlag{
{Name: "name", Usage: "名称", Bind: "appName", Trim: true},
{Name: "note", Usage: "备注", Bind: "note"},
},
}
cmd := NewLeafCommand(spec)
if err := cmd.Flags().Set("name", " Demo "); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("note", " x "); err != nil {
t.Fatal(err)
}
args, err := leafArgs(cmd, spec)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
}
if args["appName"] != "Demo" {
t.Fatalf("appName = %q, want trimmed \"Demo\"", args["appName"])
}
if args["note"] != " x " {
t.Fatalf("note = %q, want untrimmed", args["note"])
}
}