diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index dd3e13de..8cd14dea 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -181,6 +181,14 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin return "", nil } +// CallMCPToolTextOnServer invokes an MCP tool and returns its raw text response +// WITHOUT printing anything, applying the same error classification as the +// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts, +// which chain several tool calls and need each intermediate result as data. +func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) { + return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args) +} + // callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。 // 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。 func callMCPTool(toolName string, args map[string]any) error { diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go new file mode 100644 index 00000000..83e6a3b1 --- /dev/null +++ b/internal/shortcut/register.go @@ -0,0 +1,96 @@ +// 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 shortcut + +import ( + "sort" + "strconv" + + "github.com/spf13/cobra" +) + +// allShortcuts is the registry of built-in shortcuts. Service packages append to +// it via Register from their init(), keeping this package free of import cycles +// on the concrete command definitions. +var allShortcuts []Shortcut + +// Register adds one or more shortcuts to the built-in registry. Call from a +// service package's init(). +func Register(shortcuts ...Shortcut) { + allShortcuts = append(allShortcuts, shortcuts...) +} + +// Commands compiles all registered shortcuts into a slice of top-level cobra +// commands, one per service, each carrying its `+command` leaves. The result is +// merged into the root command tree by the host application. +func Commands() []*cobra.Command { + return build(allShortcuts) +} + +// All returns the registered shortcuts. Primarily for coverage tests that need +// each shortcut's declared flags (types/enums/required) to synthesize inputs. +func All() []Shortcut { + out := make([]Shortcut, len(allShortcuts)) + copy(out, allShortcuts) + return out +} + +// build groups shortcuts by service and mounts each as a leaf under its service +// parent command. +func build(shortcuts []Shortcut) []*cobra.Command { + byService := make(map[string]*cobra.Command) + var order []string + + for _, s := range shortcuts { + parent, ok := byService[s.Service] + if !ok { + parent = &cobra.Command{ + Use: s.Service, + Short: s.Service + " shortcuts", + } + byService[s.Service] = parent + order = append(order, s.Service) + } + parent.AddCommand(mount(s)) + } + + sort.Strings(order) + out := make([]*cobra.Command, 0, len(order)) + for _, svc := range order { + out = append(out, byService[svc]) + } + return out +} + +// atoiDefault parses s as an int, returning 0 when empty or malformed. +func atoiDefault(s string) int { + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +// contains reports whether v is present in list. +func contains(list []string, v string) bool { + for _, item := range list { + if item == v { + return true + } + } + return false +} diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go new file mode 100644 index 00000000..b5347a12 --- /dev/null +++ b/internal/shortcut/runner.go @@ -0,0 +1,231 @@ +// 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 shortcut + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// RuntimeContext is handed to a Shortcut's Validate and Execute hooks. It wraps +// the cobra command and exposes typed flag accessors plus a single CallMCP entry +// point so shortcut authors never touch cobra/executor plumbing directly. +type RuntimeContext struct { + cmd *cobra.Command + shortcut Shortcut +} + +// Command returns the underlying cobra command (escape hatch; prefer the typed +// accessors below). +func (rt *RuntimeContext) Command() *cobra.Command { return rt.cmd } + +// Str returns the trimmed string value of a flag, or "" if unset. +func (rt *RuntimeContext) Str(name string) string { + v, _ := rt.cmd.Flags().GetString(name) + return strings.TrimSpace(v) +} + +// Bool returns the bool value of a flag. +func (rt *RuntimeContext) Bool(name string) bool { + v, _ := rt.cmd.Flags().GetBool(name) + return v +} + +// Int returns the int value of a flag. +func (rt *RuntimeContext) Int(name string) int { + v, _ := rt.cmd.Flags().GetInt(name) + return v +} + +// StrSlice returns the string-slice value of a flag. +func (rt *RuntimeContext) StrSlice(name string) []string { + v, _ := rt.cmd.Flags().GetStringSlice(name) + return v +} + +// Changed reports whether the user explicitly set the flag on the command line. +func (rt *RuntimeContext) Changed(name string) bool { + f := rt.cmd.Flags().Lookup(name) + return f != nil && f.Changed +} + +// DryRun reports whether --dry-run is set (inherited from the root command). +func (rt *RuntimeContext) DryRun() bool { return globalBool(rt.cmd, "dry-run") } + +// Yes reports whether --yes is set (skip confirmation prompts). +func (rt *RuntimeContext) Yes() bool { return globalBool(rt.cmd, "yes") } + +// CallMCP dispatches a single MCP tool call and prints the result, reusing the +// shared helper path so the shortcut inherits DWS's error classification +// (auth/PAT/business), dry-run preview and --format/--jq/--fields output for +// free. The MCP server id is the shortcut's product (defaults to Service). +// +// Most passthrough shortcuts do all their work in one CallMCP call. +func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error { + if params == nil { + params = map[string]any{} + } + return helpers.CallMCPToolOnServer(rt.shortcut.product(), tool, params) +} + +// CallMCPData dispatches a tool call to an explicit MCP product and returns the +// PARSED response as data WITHOUT printing. This is the building block for +// multi-step ("smart") shortcuts: call a tool, read its output, feed it into the +// next call. Errors carry DWS's auth/PAT/business classification. +// +// The product is explicit (not the shortcut's own) because smart shortcuts +// routinely cross services — e.g. resolve a name via `contact` then act via +// `chat`. Reads run even under --dry-run so a preview can still resolve inputs; +// the terminal write via CallMCP still honours --dry-run. +func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + if params == nil { + params = map[string]any{} + } + text, err := helpers.CallMCPToolTextOnServer(product, tool, params) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var out map[string]any + if err := json.Unmarshal([]byte(text), &out); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return out, nil +} + +// Output prints a (typically reshaped/projected) payload honouring the root +// --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean, +// composed result instead of the raw MCP response — the output-projection +// output-formatting capability. +func (rt *RuntimeContext) Output(payload any) error { + return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) +} + +// mount compiles a Shortcut into a cobra command. +func mount(s Shortcut) *cobra.Command { + cmd := &cobra.Command{ + Use: s.Command, + Short: s.Description, + Long: s.Intent, + Hidden: s.Hidden, + } + if len(s.Tips) > 0 { + cmd.Example = " " + strings.Join(s.Tips, "\n ") + } + registerFlags(cmd, s.Flags) + + cmd.RunE = func(c *cobra.Command, _ []string) error { + rt := &RuntimeContext{cmd: c, shortcut: s} + if err := validateFlags(rt, s); err != nil { + return err + } + if s.Validate != nil { + if err := s.Validate(rt); err != nil { + return err + } + } + if !confirmRisk(rt, s) { + return nil + } + if s.Execute == nil { + return apperrors.NewInternal(fmt.Sprintf("shortcut %s %s 未实现 Execute", s.Service, s.Command)) + } + return s.Execute(rt) + } + return cmd +} + +// registerFlags declares each Flag on the command with its type/default/desc. +func registerFlags(cmd *cobra.Command, flags []Flag) { + for _, f := range flags { + switch f.Type { + case FlagBool: + cmd.Flags().Bool(f.Name, f.Default == "true", f.Desc) + case FlagInt: + cmd.Flags().Int(f.Name, atoiDefault(f.Default), f.Desc) + case FlagStringSlice: + cmd.Flags().StringSlice(f.Name, nil, f.Desc) + default: // FlagString and empty + cmd.Flags().String(f.Name, f.Default, f.Desc) + } + if f.Hidden { + _ = cmd.Flags().MarkHidden(f.Name) + } + } +} + +// validateFlags enforces the declarative Required and Enum constraints. +func validateFlags(rt *RuntimeContext, s Shortcut) error { + for _, f := range s.Flags { + if f.Required && !rt.Changed(f.Name) { + return apperrors.NewValidation(fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc)) + } + if len(f.Enum) > 0 && rt.Changed(f.Name) { + val := rt.Str(f.Name) + if !contains(f.Enum, val) { + return apperrors.NewValidation(fmt.Sprintf( + "参数 --%s 取值 %q 不合法,允许值:%s", f.Name, val, strings.Join(f.Enum, ", "))) + } + } + } + return nil +} + +// confirmRisk prompts before a write/high-risk-write shortcut unless --yes or +// --dry-run is set. Read-only shortcuts never prompt. Returns false when the +// user declines. +func confirmRisk(rt *RuntimeContext, s Shortcut) bool { + if s.risk() == RiskRead || rt.DryRun() || rt.Yes() { + return true + } + fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk()) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "yes" || answer == "y" +} + +// globalBool reads a bool flag that may live on the command, inherited flags, or +// the root's persistent flags (e.g. --dry-run/--yes injected at root). +func globalBool(cmd *cobra.Command, name string) bool { + if cmd == nil { + return false + } + sets := []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} + if root := cmd.Root(); root != nil { + sets = append(sets, root.PersistentFlags()) + } + for _, set := range sets { + if set == nil { + continue + } + if f := set.Lookup(name); f != nil { + if v, err := set.GetBool(name); err == nil { + return v + } + } + } + return false +} diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go new file mode 100644 index 00000000..ef03b784 --- /dev/null +++ b/internal/shortcut/shortcut_test.go @@ -0,0 +1,185 @@ +// 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 shortcut + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestProductDefaultsToService(t *testing.T) { + if got := (Shortcut{Service: "contact"}).product(); got != "contact" { + t.Fatalf("product() = %q, want contact", got) + } + if got := (Shortcut{Service: "contact", Product: "org"}).product(); got != "org" { + t.Fatalf("product() = %q, want org", got) + } +} + +func TestRiskDefaultsToRead(t *testing.T) { + if got := (Shortcut{}).risk(); got != RiskRead { + t.Fatalf("risk() = %q, want read", got) + } + if got := (Shortcut{Risk: RiskHighWrite}).risk(); got != RiskHighWrite { + t.Fatalf("risk() = %q, want high-risk-write", got) + } +} + +func TestMountRegistersFlagsAndUse(t *testing.T) { + s := Shortcut{ + Service: "contact", + Command: "+search-user", + Description: "search", + Flags: []Flag{ + {Name: "query", Type: FlagString, Required: true}, + {Name: "limit", Type: FlagInt, Default: "20"}, + {Name: "verbose", Type: FlagBool}, + {Name: "ids", Type: FlagStringSlice}, + }, + } + cmd := mount(s) + if cmd.Use != "+search-user" { + t.Fatalf("Use = %q, want +search-user", cmd.Use) + } + for _, name := range []string{"query", "limit", "verbose", "ids"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s not registered", name) + } + } + if v, _ := cmd.Flags().GetInt("limit"); v != 20 { + t.Errorf("limit default = %d, want 20", v) + } +} + +func TestValidateFlagsRequiredAndEnum(t *testing.T) { + s := Shortcut{ + Service: "contact", + Command: "+x", + Flags: []Flag{ + {Name: "query", Required: true}, + {Name: "order", Enum: []string{"asc", "desc"}}, + }, + } + cmd := mount(s) + + // Missing required flag → error. + rt := &RuntimeContext{cmd: cmd, shortcut: s} + if err := validateFlags(rt, s); err == nil { + t.Fatal("expected error for missing required --query") + } + + // Set required, leave enum unset → ok. + _ = cmd.Flags().Set("query", "张三") + if err := validateFlags(rt, s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Invalid enum → error. + _ = cmd.Flags().Set("order", "sideways") + if err := validateFlags(rt, s); err == nil { + t.Fatal("expected error for invalid --order enum") + } + + // Valid enum → ok. + _ = cmd.Flags().Set("order", "asc") + if err := validateFlags(rt, s); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildGroupsByService(t *testing.T) { + shortcuts := []Shortcut{ + {Service: "contact", Command: "+a", Execute: noop}, + {Service: "contact", Command: "+b", Execute: noop}, + {Service: "calendar", Command: "+c", Execute: noop}, + } + cmds := build(shortcuts) + if len(cmds) != 2 { + t.Fatalf("got %d service commands, want 2", len(cmds)) + } + byName := map[string]*cobra.Command{} + for _, c := range cmds { + byName[c.Use] = c + } + if got := len(byName["contact"].Commands()); got != 2 { + t.Errorf("contact has %d leaves, want 2", got) + } + if got := len(byName["calendar"].Commands()); got != 1 { + t.Errorf("calendar has %d leaves, want 1", got) + } +} + +func noop(_ *RuntimeContext) error { return nil } + +func TestValidationHelpers(t *testing.T) { + s := Shortcut{ + Service: "x", Command: "+y", + Flags: []Flag{ + {Name: "a"}, {Name: "b"}, {Name: "c"}, + {Name: "n", Type: FlagInt}, + }, + } + cmd := mount(s) + rt := &RuntimeContext{cmd: cmd, shortcut: s} + + // nothing set + if err := rt.MutuallyExclusive("a", "b"); err != nil { + t.Errorf("MutuallyExclusive with none set should pass: %v", err) + } + if err := rt.AtLeastOne("a", "b"); err == nil { + t.Error("AtLeastOne with none set should fail") + } + if err := rt.ExactlyOne("a", "b"); err == nil { + t.Error("ExactlyOne with none set should fail") + } + + _ = cmd.Flags().Set("a", "1") + if err := rt.MutuallyExclusive("a", "b"); err != nil { + t.Errorf("MutuallyExclusive with one set should pass: %v", err) + } + if err := rt.AtLeastOne("a", "b"); err != nil { + t.Errorf("AtLeastOne with one set should pass: %v", err) + } + if err := rt.ExactlyOne("a", "b"); err != nil { + t.Errorf("ExactlyOne with one set should pass: %v", err) + } + + _ = cmd.Flags().Set("b", "1") + if err := rt.MutuallyExclusive("a", "b"); err == nil { + t.Error("MutuallyExclusive with two set should fail") + } + if err := rt.ExactlyOne("a", "b"); err == nil { + t.Error("ExactlyOne with two set should fail") + } + + // RangeInt + _ = cmd.Flags().Set("n", "50") + if err := rt.RangeInt("n", 1, 30); err == nil { + t.Error("RangeInt 50 not in [1,30] should fail") + } + _ = cmd.Flags().Set("n", "20") + if err := rt.RangeInt("n", 1, 30); err != nil { + t.Errorf("RangeInt 20 in [1,30] should pass: %v", err) + } + + // RequireAll + if err := rt.RequireAll("a", "c"); err == nil { + t.Error("RequireAll with c unset should fail") + } + _ = cmd.Flags().Set("c", "1") + if err := rt.RequireAll("a", "c"); err != nil { + t.Errorf("RequireAll with both set should pass: %v", err) + } +} diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go new file mode 100644 index 00000000..ff0816ed --- /dev/null +++ b/internal/shortcut/types.go @@ -0,0 +1,126 @@ +// 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 shortcut provides a declarative framework for high-fidelity CLI +// commands (shortcuts) on top of the DingTalk MCP runtime. +// +// A Shortcut is a curated, thin wrapper over a raw MCP tool call: it declares +// its flags, validation and execution logic once, and the framework compiles it +// into a cobra command wired to the shared executor.Runner, output formatting, +// dry-run and identity handling. This provides a `+command` +// shortcut layer, but executes through DWS's MCP dispatch instead of a native SDK. +// +// Shortcuts are surfaced as `dws +` (e.g. `dws contact +// +search-user`). The `+` prefix keeps them visually distinct from the +// dynamically-discovered MCP leaf commands and from hand-written helper commands. +package shortcut + +// Risk classifies the side effect of running a shortcut. It drives whether a +// confirmation prompt is required before execution (see internal/safety). +type Risk string + +const ( + // RiskRead is a read-only operation; never prompts. + RiskRead Risk = "read" + // RiskWrite mutates state; may prompt unless --yes is set. + RiskWrite Risk = "write" + // RiskHighWrite is a destructive/irreversible operation; requires explicit + // confirmation (or --yes) before execution. + RiskHighWrite Risk = "high-risk-write" +) + +// FlagType is the pflag value type a Flag registers as. +type FlagType string + +const ( + FlagString FlagType = "string" + FlagBool FlagType = "bool" + FlagInt FlagType = "int" + FlagStringSlice FlagType = "string_slice" +) + +// Flag is a declarative CLI flag definition. The runner registers each Flag onto +// the generated cobra command, applying defaults, and validates Enum/Required +// before the Execute hook runs. +type Flag struct { + // Name is the long flag name (kebab-case), e.g. "user-ids". + Name string + // Type is the value type; defaults to FlagString when empty. + Type FlagType + // Default is the default value rendered as a string. + Default string + // Desc is the help text shown in --help. + Desc string + // Required, when true, makes the framework error if the flag is not set. + Required bool + // Enum, when non-empty, restricts the accepted values (string flags only). + Enum []string + // Hidden hides the flag from --help while keeping it usable. + Hidden bool +} + +// Shortcut is the declarative definition of a single high-fidelity command. +// +// The zero-value is not usable; Service, Command and Execute are required. +// The framework injects the global --format/--dry-run/--jq/--yes flags from the +// root command, so shortcuts must not redeclare them. +type Shortcut struct { + // Service is the top-level command group, e.g. "contact". Multiple + // shortcuts sharing a Service are mounted under the same parent command. + Service string + // Command is the leaf name including its "+" prefix, e.g. "+search-user". + Command string + // Product is the canonical MCP product id used to build the invocation. + // Defaults to Service when empty. + Product string + // Description is the one-line help shown in --help. + Description string + // Intent is a fuller natural-language description of what the command does + // and WHEN to reach for it. Unlike the terse Description, it is written for + // human discovery and AI-agent intent matching (e.g. "当你只知道某人姓名、需要 + // 拿到其 userId 以便后续发消息或指派任务时使用"). Surfaced in `--help` (as the + // long description) and in `dws shortcut list`. + Intent string + // Risk classifies the side effect; defaults to RiskRead when empty. + Risk Risk + // Flags are the command-specific flags. Global flags are injected separately. + Flags []Flag + // Tips are optional usage examples appended to --help. + Tips []string + // Hidden hides the command from listings while keeping it invocable. + Hidden bool + + // Validate optionally checks resolved flag values before execution. Return a + // non-nil error to abort with a validation message. Runs after built-in + // Required/Enum checks. + Validate func(rt *RuntimeContext) error + // Execute performs the shortcut. It is required. Typically it builds a + // params map from rt's flags, calls rt.CallMCP, and rt.Output's the result. + Execute func(rt *RuntimeContext) error +} + +// product returns the canonical MCP product id for building invocations. +func (s Shortcut) product() string { + if s.Product != "" { + return s.Product + } + return s.Service +} + +// risk returns the effective risk, defaulting to read. +func (s Shortcut) risk() Risk { + if s.Risk == "" { + return RiskRead + } + return s.Risk +} diff --git a/internal/shortcut/validate.go b/internal/shortcut/validate.go new file mode 100644 index 00000000..1fea10e2 --- /dev/null +++ b/internal/shortcut/validate.go @@ -0,0 +1,106 @@ +// 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 shortcut + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Cross-field validation helpers for use inside a Shortcut's Validate hook — +// the systematic cross-field validation (MutuallyExclusive / +// AtLeastOne / ExactlyOne / range checks) so per-command validation reads the +// same everywhere instead of being hand-rolled each time. + +// setFlags returns the subset of the given flag names the user actually set. +func (rt *RuntimeContext) setFlags(flags ...string) []string { + var set []string + for _, f := range flags { + if rt.Changed(f) { + set = append(set, f) + } + } + return set +} + +func dashed(flags []string) string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = "--" + f + } + return strings.Join(out, "、") +} + +// MutuallyExclusive returns a validation error if more than one of the flags is +// set. Zero or one set is allowed. +func (rt *RuntimeContext) MutuallyExclusive(flags ...string) error { + if set := rt.setFlags(flags...); len(set) > 1 { + return apperrors.NewValidation(fmt.Sprintf( + "参数 %s 互斥,只能指定其一(当前指定了 %s)", dashed(flags), dashed(set))) + } + return nil +} + +// AtLeastOne returns a validation error if none of the flags is set. +func (rt *RuntimeContext) AtLeastOne(flags ...string) error { + if len(rt.setFlags(flags...)) == 0 { + return apperrors.NewValidation(fmt.Sprintf( + "请至少指定 %s 之一", dashed(flags))) + } + return nil +} + +// ExactlyOne returns a validation error unless exactly one of the flags is set. +func (rt *RuntimeContext) ExactlyOne(flags ...string) error { + set := rt.setFlags(flags...) + switch len(set) { + case 1: + return nil + case 0: + return apperrors.NewValidation(fmt.Sprintf("请指定 %s 之一", dashed(flags))) + default: + return apperrors.NewValidation(fmt.Sprintf( + "参数 %s 只能指定其一(当前指定了 %s)", dashed(flags), dashed(set))) + } +} + +// RangeInt validates that an int flag (when set) is within [min, max]. +func (rt *RuntimeContext) RangeInt(flag string, min, max int) error { + if !rt.Changed(flag) { + return nil + } + v := rt.Int(flag) + if v < min || v > max { + return apperrors.NewValidation(fmt.Sprintf( + "参数 --%s 取值 %d 超出范围,应在 %d–%d 之间", flag, v, min, max)) + } + return nil +} + +// RequireAll returns a validation error if any of the flags is not set. Useful +// for "these flags come as a group" constraints (e.g. --start requires --end). +func (rt *RuntimeContext) RequireAll(flags ...string) error { + var missing []string + for _, f := range flags { + if !rt.Changed(f) { + missing = append(missing, f) + } + } + if len(missing) > 0 { + return apperrors.NewValidation(fmt.Sprintf("缺少必填参数 %s", dashed(missing))) + } + return nil +}