The discovery envelope is remote data, but four of its shapes were forwarded to pflag registration calls that panic: - a flag named 'params' or 'json' collides with the reserved payload flags registered at the end of ApplyBindings (the original pre-1.0.32 lockout: "chat_permission_grant flag redefined: params") - two bindings resolving to the same long flag name (cross-binding duplicate primary/alias; the existing dedup map was per-binding only) - two flags claiming the same shorthand - a multi-character shorthand Because the command tree is built from the cached envelope before Cobra dispatches anything, any of these aborted every CLI invocation. Add canRegisterFlag (skip duplicate/reserved long names; CollectBindings already tolerates missing flags via Lookup→nil→continue, and the value stays reachable through --params) and safeShorthand (drop invalid or taken shorthands, keep the long flag) and apply them at every envelope-driven registration site in ApplyBindings and registerPositionalAliasFlags. The trailing --json/--params registration is also made idempotent. Complements #447: that PR adds the escape hatch when the build panics; this removes the known panic vectors so the escape hatch should never be needed for them.
1140 lines
38 KiB
Go
1140 lines
38 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package compat
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
type ValueKind string
|
|
|
|
const (
|
|
ValueString ValueKind = "string"
|
|
ValueInt ValueKind = "int"
|
|
ValueFloat ValueKind = "float"
|
|
ValueBool ValueKind = "bool"
|
|
ValueStringSlice ValueKind = "string_slice"
|
|
ValueIntSlice ValueKind = "int_slice"
|
|
ValueFloatSlice ValueKind = "float_slice"
|
|
ValueBoolSlice ValueKind = "bool_slice"
|
|
ValueJSON ValueKind = "json"
|
|
)
|
|
|
|
type Target struct {
|
|
CanonicalProduct string
|
|
Tool string
|
|
}
|
|
|
|
type FlagBinding struct {
|
|
FlagName string
|
|
Alias string
|
|
// Aliases are additional hidden flag names that map to the same MCP
|
|
// parameter. Any of them being set satisfies Required, and the value
|
|
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
|
|
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
|
|
Aliases []string
|
|
// PipelineLocal, when true, marks this binding as CLI-side only — its
|
|
// value is consumed by the pipeline executor (e.g. as an HTTP
|
|
// download destination) and NOT forwarded to any MCP tool's params.
|
|
PipelineLocal bool
|
|
Short string
|
|
Property string
|
|
Kind ValueKind
|
|
Usage string
|
|
Required bool
|
|
// Default is the cobra-level flag default value as a string. Parsed
|
|
// into the Kind-appropriate primitive at registration time. Empty
|
|
// string keeps the existing zero-value default. This only affects
|
|
// what cobra renders in --help (the "(default ...)" suffix); it does
|
|
// NOT inject the value into MCP params on its own — CollectBindings
|
|
// still gates writes by user-changed flags via firstChangedFlag.
|
|
Default string
|
|
// Positional binds this parameter to a positional CLI argument rather
|
|
// than a --flag. PositionalIndex is the 0-based slot.
|
|
Positional bool
|
|
PositionalIndex int
|
|
}
|
|
|
|
type Normalizer func(cmd *cobra.Command, params map[string]any) error
|
|
|
|
type Route struct {
|
|
Use string
|
|
Aliases []string
|
|
Short string
|
|
Long string
|
|
Example string
|
|
Hidden bool
|
|
Target Target
|
|
Bindings []FlagBinding
|
|
// Pipeline, when non-empty, replaces the single-tool dispatch with a
|
|
// multi-step orchestration. NewDirectCommand sees this and wires the
|
|
// pipeline executor into RunE instead of the standard
|
|
// invoke-then-output flow. See internal/compat/pipeline.go.
|
|
Pipeline []market.PipelineStep
|
|
Normalizer Normalizer
|
|
// OutputTransform, when non-nil, post-processes the MCP response payload
|
|
// (rename / drop / columns) before the formatter emits it. Wired up from
|
|
// CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5.
|
|
OutputTransform func(map[string]any) map[string]any
|
|
// RejectPositional forces cobra.NoArgs on the generated leaf when no
|
|
// positional bindings exist, so stray positional tokens (e.g.
|
|
// `dws contact label list unexpected`) exit non-zero. Sourced from
|
|
// CLIToolOverride.RejectPositional. Ignored when the leaf already
|
|
// declares positional bindings — their arity validator wins.
|
|
RejectPositional bool
|
|
// RequireTogether groups flag aliases that must all be set together (or
|
|
// all left unset). Each inner slice produces one PreRunE cross-field
|
|
// check. Sourced from CLIToolOverride.RequireTogether. Unknown flag
|
|
// names are logged and skipped at command-build time (consistent with
|
|
// applyFlagConstraints semantics).
|
|
RequireTogether [][]string
|
|
}
|
|
|
|
type CommandFactory func(runner executor.Runner) *cobra.Command
|
|
|
|
var (
|
|
registryMu sync.Mutex
|
|
publicFactories []CommandFactory
|
|
fallbackFactories []CommandFactory
|
|
)
|
|
|
|
func RegisterPublic(factory CommandFactory) {
|
|
registryMu.Lock()
|
|
defer registryMu.Unlock()
|
|
publicFactories = append(publicFactories, factory)
|
|
}
|
|
|
|
func RegisterFallback(factory CommandFactory) {
|
|
registryMu.Lock()
|
|
defer registryMu.Unlock()
|
|
fallbackFactories = append(fallbackFactories, factory)
|
|
}
|
|
|
|
func NewPublicCommands(runner executor.Runner) []*cobra.Command {
|
|
return buildFactories(publicFactories, runner)
|
|
}
|
|
|
|
func NewFallbackCommands(runner executor.Runner) []*cobra.Command {
|
|
return buildFactories(fallbackFactories, runner)
|
|
}
|
|
|
|
// NewGroupCommand delegates to cobracmd.NewGroupCommand for backward compatibility.
|
|
var NewGroupCommand = cobracmd.NewGroupCommand
|
|
|
|
func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
|
// Compute positional arity. Two counts:
|
|
// - totalMax: the highest PositionalIndex+1 across all positional bindings
|
|
// (caps how many trailing args cobra accepts).
|
|
// - strictMin: the highest PositionalIndex+1 among "pure" positional
|
|
// bindings (no flag aliases). Backward-compat: any pure positional
|
|
// binding implies required arity at parse time, regardless of Required.
|
|
//
|
|
// Dual-mode positional bindings (positional + envelope-declared flag
|
|
// aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`)
|
|
// are counted into totalMax but excluded from strictMin so a flag-only
|
|
// invocation parses; their required-presence is enforced by
|
|
// validateRequiredPositionalBindings inside RunE.
|
|
//
|
|
// For positional bindings, buildOverrideBindings populates FlagName /
|
|
// Aliases only when the envelope explicitly declared them, so the
|
|
// dual-mode detection here is unambiguous.
|
|
strictMin := 0
|
|
totalMax := 0
|
|
for _, b := range route.Bindings {
|
|
if !b.Positional {
|
|
continue
|
|
}
|
|
if b.PositionalIndex+1 > totalMax {
|
|
totalMax = b.PositionalIndex + 1
|
|
}
|
|
hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0
|
|
if !hasFlagAlias && b.PositionalIndex+1 > strictMin {
|
|
strictMin = b.PositionalIndex + 1
|
|
}
|
|
}
|
|
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
|
|
switch {
|
|
case totalMax == 0:
|
|
// No positional bindings: default to ArbitraryArgs unless the
|
|
// envelope explicitly asked for strict no-positional. We only
|
|
// honor RejectPositional in this branch because leaves with
|
|
// positional bindings already get a stricter validator below;
|
|
// flipping them to NoArgs would silently break valid invocations.
|
|
if route.RejectPositional {
|
|
argsValidator = cobra.NoArgs
|
|
} else {
|
|
argsValidator = cobra.ArbitraryArgs
|
|
}
|
|
case strictMin > 0 && strictMin == totalMax:
|
|
argsValidator = cobra.MinimumNArgs(strictMin)
|
|
case strictMin > 0:
|
|
argsValidator = cobra.RangeArgs(strictMin, totalMax)
|
|
default:
|
|
argsValidator = cobra.MaximumNArgs(totalMax)
|
|
}
|
|
|
|
// Extend Use with [<placeholder>] tokens for positional bindings so
|
|
// `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded
|
|
// helper commands' style (e.g. devdoc article search [keyword]).
|
|
use := route.Use
|
|
if totalMax > 0 {
|
|
ordered := make([]FlagBinding, 0, totalMax)
|
|
for _, b := range route.Bindings {
|
|
if b.Positional {
|
|
ordered = append(ordered, b)
|
|
}
|
|
}
|
|
sort.SliceStable(ordered, func(i, j int) bool {
|
|
return ordered[i].PositionalIndex < ordered[j].PositionalIndex
|
|
})
|
|
var sb strings.Builder
|
|
sb.WriteString(use)
|
|
for _, b := range ordered {
|
|
name := strings.TrimSpace(b.Property)
|
|
if name == "" {
|
|
name = strings.TrimSpace(b.FlagName)
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
sb.WriteString(" [")
|
|
sb.WriteString(name)
|
|
sb.WriteString("]")
|
|
}
|
|
use = sb.String()
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: use,
|
|
Aliases: append([]string(nil), route.Aliases...),
|
|
Short: route.Short,
|
|
Long: route.Long,
|
|
Example: route.Example,
|
|
Hidden: route.Hidden,
|
|
Args: argsValidator,
|
|
DisableAutoGenTag: true,
|
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
|
// Envelope-declared cross-field "must be set together" checks.
|
|
// Returns the first failing group so users see one actionable
|
|
// error per invocation (mirrors cobra's MarkFlagsOneRequired UX).
|
|
return validateRequireTogether(cmd, route.RequireTogether)
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
jsonPayload, err := cmd.Flags().GetString("json")
|
|
if err != nil {
|
|
return apperrors.NewInternal("failed to read --json")
|
|
}
|
|
paramsPayload, err := cmd.Flags().GetString("params")
|
|
if err != nil {
|
|
return apperrors.NewInternal("failed to read --params")
|
|
}
|
|
baseParams, err := executor.MergePayloads(jsonPayload, paramsPayload, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bindingParams, err := CollectBindings(cmd, route.Bindings, baseParams)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params := baseParams
|
|
for key, value := range bindingParams {
|
|
params[key] = value
|
|
}
|
|
|
|
// Inject positional args into params according to each binding's
|
|
// PositionalIndex. Pure positional bindings are not registered as
|
|
// flags; dual-mode positional bindings (positional + alias) only
|
|
// fall through to positional injection when their flag aliases
|
|
// were not used (collectPositionalBindings skips when params
|
|
// already contains the property).
|
|
if err := collectPositionalBindings(args, route.Bindings, params); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Collect schema-derived flags (from buildFlagsFromDetailSchema)
|
|
// that are not covered by explicit bindings.
|
|
collectSchemaFlags(cmd, route.Bindings, params)
|
|
|
|
// Required-presence check for positional bindings — must run after
|
|
// both flag (CollectBindings) and positional (collectPositionalBindings)
|
|
// have had a chance to populate params.
|
|
if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil {
|
|
return err
|
|
}
|
|
|
|
if route.Normalizer != nil {
|
|
if err := route.Normalizer(cmd, params); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if blocked, _ := params["_blocked"].(bool); blocked {
|
|
// Interactive confirmation for destructive operations (consistent with Helper commands)
|
|
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ This is a destructive operation.")
|
|
fmt.Fprint(cmd.ErrOrStderr(), "Confirm? (yes/no): ")
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
answer, _ := reader.ReadString('\n')
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
|
|
if answer != "yes" && answer != "y" {
|
|
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
|
|
return nil
|
|
}
|
|
// User confirmed, continue execution
|
|
delete(params, "_blocked")
|
|
}
|
|
|
|
// §pipeline: when the override declares a multi-step pipeline,
|
|
// dispatch via the pipeline executor instead of the single-tool
|
|
// invoke-then-output flow. The executor reads flag values by
|
|
// alias (so $flag.<alias> templates resolve), walks each step,
|
|
// handles polling + downloads, and returns the last "call"
|
|
// step's response as the payload to the formatter.
|
|
if len(route.Pipeline) > 0 {
|
|
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
|
|
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := executor.Result{
|
|
Invocation: executor.NewCompatibilityInvocation(
|
|
cobracmd.LegacyCommandPath(cmd),
|
|
route.Target.CanonicalProduct,
|
|
"pipeline",
|
|
params,
|
|
),
|
|
Response: resp,
|
|
}
|
|
if route.OutputTransform != nil && result.Response != nil {
|
|
result.Response = route.OutputTransform(result.Response)
|
|
}
|
|
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
|
}
|
|
|
|
invocation := executor.NewCompatibilityInvocation(
|
|
cobracmd.LegacyCommandPath(cmd),
|
|
route.Target.CanonicalProduct,
|
|
route.Target.Tool,
|
|
params,
|
|
)
|
|
if dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run"); dryRun {
|
|
invocation.DryRun = true
|
|
}
|
|
result, err := runner.Run(cmd.Context(), invocation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if route.OutputTransform != nil && result.Response != nil {
|
|
result.Response = route.OutputTransform(result.Response)
|
|
}
|
|
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
|
},
|
|
}
|
|
|
|
ApplyBindings(cmd, route.Bindings)
|
|
return cmd
|
|
}
|
|
|
|
// NewCuratedCommand creates a DirectCommand with override priority so it wins
|
|
// over auto-generated MCP overlay commands during command tree merging.
|
|
func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command {
|
|
cmd := NewDirectCommand(route, runner)
|
|
cli.SetOverridePriority(cmd, 100)
|
|
return cmd
|
|
}
|
|
|
|
// parseFlagDefault converts a string-form envelope default into the typed
|
|
// primitives used by pflag's *P helpers. Unparseable values silently fall
|
|
// back to the type's zero value so a malformed envelope downgrades to
|
|
// "no default in --help" rather than a panic at startup. The slice form
|
|
// splits on commas and trims whitespace, mirroring pflag.StringSlice
|
|
// behavior; empty/whitespace-only segments are dropped.
|
|
func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
switch kind {
|
|
case ValueString, ValueJSON:
|
|
// Preserve raw (not trimmed) so explicitly-padded defaults survive.
|
|
defStr = raw
|
|
case ValueInt:
|
|
if trimmed != "" {
|
|
if v, err := strconv.Atoi(trimmed); err == nil {
|
|
defInt = v
|
|
}
|
|
}
|
|
case ValueFloat:
|
|
if trimmed != "" {
|
|
if v, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
|
defFloat = v
|
|
}
|
|
}
|
|
case ValueBool:
|
|
if trimmed != "" {
|
|
if v, err := strconv.ParseBool(trimmed); err == nil {
|
|
defBool = v
|
|
}
|
|
}
|
|
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
|
if trimmed != "" {
|
|
for _, p := range strings.Split(trimmed, ",") {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
defSlice = append(defSlice, t)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// canRegisterFlag reports whether a long flag named name can be registered
|
|
// on cmd without panicking pflag ("flag redefined"). The reserved payload
|
|
// names are excluded too: ApplyBindings unconditionally registers hidden
|
|
// --json/--params after the bindings loop. The envelope is remote data —
|
|
// a duplicate or reserved name there must degrade to "flag unavailable",
|
|
// never abort the process.
|
|
func canRegisterFlag(cmd *cobra.Command, name string) bool {
|
|
if name == "" || name == "json" || name == "params" {
|
|
return false
|
|
}
|
|
return cmd.Flags().Lookup(name) == nil
|
|
}
|
|
|
|
// safeShorthand returns short when it is a single-character shorthand not
|
|
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
|
// pflag panics on both multi-character and duplicate shorthands.
|
|
func safeShorthand(cmd *cobra.Command, short string) string {
|
|
short = strings.TrimSpace(short)
|
|
if len(short) != 1 {
|
|
return ""
|
|
}
|
|
if cmd.Flags().ShorthandLookup(short) != nil {
|
|
return ""
|
|
}
|
|
return short
|
|
}
|
|
|
|
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
|
for _, binding := range bindings {
|
|
// Positional bindings are collected from cobra args rather than flags.
|
|
// Exception: dual-mode bindings (positional + envelope-declared flag
|
|
// aliases) also register the aliases so users can pass either
|
|
// `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced
|
|
// later by validateRequiredPositionalBindings instead of MarkFlagRequired.
|
|
if binding.Positional {
|
|
primary := strings.TrimSpace(binding.FlagName)
|
|
alias := strings.TrimSpace(binding.Alias)
|
|
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
|
continue
|
|
}
|
|
registerPositionalAliasFlags(cmd, binding)
|
|
continue
|
|
}
|
|
primary := strings.TrimSpace(binding.FlagName)
|
|
if primary == "" {
|
|
continue
|
|
}
|
|
alias := strings.TrimSpace(binding.Alias)
|
|
if alias == primary {
|
|
alias = ""
|
|
}
|
|
// Dedupe extra aliases against primary + single alias and each other.
|
|
var extras []string
|
|
if len(binding.Aliases) > 0 {
|
|
seen := map[string]bool{primary: true, "json": true, "params": true}
|
|
if alias != "" {
|
|
seen[alias] = true
|
|
}
|
|
extras = make([]string, 0, len(binding.Aliases))
|
|
for _, a := range binding.Aliases {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" || seen[a] {
|
|
continue
|
|
}
|
|
seen[a] = true
|
|
extras = append(extras, a)
|
|
}
|
|
}
|
|
|
|
// Parse binding.Default once per binding into Kind-typed values used
|
|
// by both the primary and hidden-alias registrations below. Hidden
|
|
// aliases share the same default so users typing the legacy alias
|
|
// see consistent --help text and zero-value behavior.
|
|
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
|
|
|
registerHidden := func(name string, suffix string) {
|
|
if !canRegisterFlag(cmd, name) {
|
|
return
|
|
}
|
|
switch binding.Kind {
|
|
case ValueString:
|
|
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
|
case ValueInt:
|
|
cmd.Flags().Int(name, defInt, binding.Usage+suffix)
|
|
case ValueFloat:
|
|
cmd.Flags().Float64(name, defFloat, binding.Usage+suffix)
|
|
case ValueBool:
|
|
cmd.Flags().Bool(name, defBool, binding.Usage+suffix)
|
|
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
|
cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix)
|
|
case ValueJSON:
|
|
cmd.Flags().String(name, defStr, binding.Usage+suffix)
|
|
}
|
|
_ = cmd.Flags().MarkHidden(name)
|
|
}
|
|
|
|
if !canRegisterFlag(cmd, primary) {
|
|
// Duplicate or reserved primary name in the envelope. Skip the
|
|
// whole binding: CollectBindings tolerates the missing flag
|
|
// (Lookup → nil → continue) and the value can still be supplied
|
|
// via the --params payload.
|
|
continue
|
|
}
|
|
short := safeShorthand(cmd, binding.Short)
|
|
switch binding.Kind {
|
|
case ValueString:
|
|
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
|
|
case ValueInt:
|
|
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
|
|
case ValueFloat:
|
|
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
|
|
case ValueBool:
|
|
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
|
|
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
|
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
|
|
case ValueJSON:
|
|
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
|
|
}
|
|
registerHidden(alias, " (alias)")
|
|
for _, extra := range extras {
|
|
registerHidden(extra, " (alias)")
|
|
}
|
|
if binding.Required {
|
|
// When no hidden aliases exist, lean on cobra's native required
|
|
// validation for the best UX (colored error, shown in --help).
|
|
// When aliases exist, CollectBindings does its own "any-of-these
|
|
// is set" check so users who type the hidden alias do not hit
|
|
// cobra yelling about the primary being missing.
|
|
if alias == "" && len(extras) == 0 {
|
|
_ = cmd.MarkFlagRequired(primary)
|
|
}
|
|
}
|
|
}
|
|
if cmd.Flags().Lookup("json") == nil {
|
|
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
|
}
|
|
if cmd.Flags().Lookup("params") == nil {
|
|
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
|
}
|
|
_ = cmd.Flags().MarkHidden("json")
|
|
_ = cmd.Flags().MarkHidden("params")
|
|
}
|
|
|
|
// registerPositionalAliasFlags registers the visible primary flag and any
|
|
// hidden aliases for a "dual-mode" positional binding (envelope:
|
|
// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is
|
|
// intentionally deferred to validateRequiredPositionalBindings — cobra's
|
|
// MarkFlagRequired would yell even when the user supplied the value as a
|
|
// positional arg.
|
|
func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
|
|
primary := strings.TrimSpace(binding.FlagName)
|
|
alias := strings.TrimSpace(binding.Alias)
|
|
if alias == primary {
|
|
alias = ""
|
|
}
|
|
|
|
// Dedupe extras against primary + alias and reserved internal names.
|
|
seen := map[string]bool{"json": true, "params": true}
|
|
if primary != "" {
|
|
seen[primary] = true
|
|
}
|
|
if alias != "" {
|
|
seen[alias] = true
|
|
}
|
|
extras := make([]string, 0, len(binding.Aliases))
|
|
for _, a := range binding.Aliases {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" || seen[a] {
|
|
continue
|
|
}
|
|
seen[a] = true
|
|
extras = append(extras, a)
|
|
}
|
|
|
|
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
|
|
|
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
|
|
if !canRegisterFlag(cmd, name) {
|
|
return
|
|
}
|
|
short := ""
|
|
if withShort {
|
|
short = safeShorthand(cmd, binding.Short)
|
|
}
|
|
usage := binding.Usage + usageSuffix
|
|
switch binding.Kind {
|
|
case ValueString:
|
|
cmd.Flags().StringP(name, short, defStr, usage)
|
|
case ValueInt:
|
|
cmd.Flags().IntP(name, short, defInt, usage)
|
|
case ValueFloat:
|
|
cmd.Flags().Float64P(name, short, defFloat, usage)
|
|
case ValueBool:
|
|
cmd.Flags().BoolP(name, short, defBool, usage)
|
|
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
|
cmd.Flags().StringSliceP(name, short, defSlice, usage)
|
|
case ValueJSON:
|
|
cmd.Flags().StringP(name, short, defStr, usage+" (JSON)")
|
|
default:
|
|
cmd.Flags().StringP(name, short, defStr, usage)
|
|
}
|
|
if hidden {
|
|
_ = cmd.Flags().MarkHidden(name)
|
|
}
|
|
}
|
|
|
|
register(primary, true, false, "")
|
|
register(alias, false, true, " (alias)")
|
|
for _, e := range extras {
|
|
register(e, false, true, " (alias)")
|
|
}
|
|
}
|
|
|
|
// collectPositionalBindings pulls positional args according to the bindings
|
|
// and injects them into params[property]. Missing slots are skipped (cobra
|
|
// arity validation already ran before RunE).
|
|
func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error {
|
|
for _, binding := range bindings {
|
|
if !binding.Positional {
|
|
continue
|
|
}
|
|
property := strings.TrimSpace(binding.Property)
|
|
if property == "" {
|
|
continue
|
|
}
|
|
// Dual-mode positional: if the user already provided the value via a
|
|
// flag alias (CollectBindings wrote it), honor flag > positional.
|
|
if _, ok := params[property]; ok {
|
|
continue
|
|
}
|
|
if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) {
|
|
continue
|
|
}
|
|
raw := args[binding.PositionalIndex]
|
|
switch binding.Kind {
|
|
case ValueInt:
|
|
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property))
|
|
}
|
|
params[property] = v
|
|
case ValueFloat:
|
|
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property))
|
|
}
|
|
params[property] = v
|
|
case ValueBool:
|
|
v, err := strconv.ParseBool(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property))
|
|
}
|
|
params[property] = v
|
|
default:
|
|
params[property] = raw
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateRequiredPositionalBindings enforces required-presence for positional
|
|
// bindings whose original envelope spec set required=true. The arity validator
|
|
// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs
|
|
// excluding the dual slot) so a flag-only invocation is permitted; this check
|
|
// closes the loop by rejecting the case where neither the positional arg nor
|
|
// any flag alias was supplied.
|
|
func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error {
|
|
for _, binding := range bindings {
|
|
if !binding.Positional || !binding.Required {
|
|
continue
|
|
}
|
|
property := strings.TrimSpace(binding.Property)
|
|
if property == "" {
|
|
continue
|
|
}
|
|
if v, ok := params[property]; ok {
|
|
if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
|
|
continue
|
|
}
|
|
}
|
|
// Compose candidate flag names so the error message points users at
|
|
// the first writable label even for flag-only invocations.
|
|
primary := strings.TrimSpace(binding.FlagName)
|
|
alias := strings.TrimSpace(binding.Alias)
|
|
if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed {
|
|
continue
|
|
}
|
|
display := primary
|
|
if display == "" {
|
|
display = alias
|
|
}
|
|
if display == "" && len(binding.Aliases) > 0 {
|
|
display = binding.Aliases[0]
|
|
}
|
|
if display == "" {
|
|
return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property))
|
|
}
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that
|
|
// have no explicit FlagBinding. This bridges the gap for plugin-defined tools
|
|
// whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags.
|
|
func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) {
|
|
// Build a set of flag names already covered by bindings.
|
|
bound := make(map[string]bool, len(bindings)*2)
|
|
for _, b := range bindings {
|
|
if n := strings.TrimSpace(b.FlagName); n != "" {
|
|
bound[n] = true
|
|
}
|
|
if a := strings.TrimSpace(b.Alias); a != "" {
|
|
bound[a] = true
|
|
}
|
|
for _, extra := range b.Aliases {
|
|
if e := strings.TrimSpace(extra); e != "" {
|
|
bound[e] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reserved/internal flags that should never be forwarded as tool params.
|
|
skip := map[string]bool{
|
|
"json": true, "params": true, "help": true,
|
|
"format": true, "fields": true, "jq": true,
|
|
"debug": true, "verbose": true, "dry-run": true,
|
|
"yes": true, "mock": true, "timeout": true,
|
|
"client-id": true, "client-secret": true,
|
|
}
|
|
|
|
cmd.Flags().Visit(func(f *pflag.Flag) {
|
|
if bound[f.Name] || skip[f.Name] {
|
|
return
|
|
}
|
|
// Convert flag name back to the original parameter name (kebab → snake/camel)
|
|
// For simplicity, use the flag name as-is since MCP tools typically
|
|
// use snake_case which maps to kebab-case flags.
|
|
paramName := toOriginalParamName(f.Name)
|
|
if _, exists := params[paramName]; exists {
|
|
return // already set by --json/--params
|
|
}
|
|
|
|
switch f.Value.Type() {
|
|
case "int":
|
|
if v, err := cmd.Flags().GetInt(f.Name); err == nil {
|
|
params[paramName] = v
|
|
}
|
|
case "bool":
|
|
if v, err := cmd.Flags().GetBool(f.Name); err == nil {
|
|
params[paramName] = v
|
|
}
|
|
case "stringSlice":
|
|
if v, err := cmd.Flags().GetStringSlice(f.Name); err == nil {
|
|
params[paramName] = v
|
|
}
|
|
default:
|
|
if v, err := cmd.Flags().GetString(f.Name); err == nil {
|
|
params[paramName] = v
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// toOriginalParamName converts a kebab-case flag name back to the original
|
|
// MCP parameter name. Since toKebabCase converts both camelCase and snake_case
|
|
// to kebab-case, we default to snake_case (the MCP convention).
|
|
func toOriginalParamName(flagName string) string {
|
|
return strings.ReplaceAll(flagName, "-", "_")
|
|
}
|
|
|
|
// firstChangedFlag returns the first name (in order) whose cobra flag has
|
|
// been set by the user. Whitespace-only or empty entries are skipped.
|
|
// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias >
|
|
// extraAliases in declaration order.
|
|
func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) {
|
|
for _, n := range names {
|
|
n = strings.TrimSpace(n)
|
|
if n == "" {
|
|
continue
|
|
}
|
|
if cobracmd.FlagChanged(cmd, n) {
|
|
return n, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) {
|
|
if existing == nil {
|
|
existing = map[string]any{}
|
|
}
|
|
params := make(map[string]any)
|
|
for _, binding := range bindings {
|
|
// Pipeline-local flags exist purely for the pipeline executor
|
|
// (e.g. --output destination paths) and must never be forwarded
|
|
// to MCP tools as params, otherwise the upstream API would
|
|
// either reject the unknown field or silently store junk.
|
|
if binding.PipelineLocal {
|
|
continue
|
|
}
|
|
if binding.Positional {
|
|
// Pure positional (no flag aliases) is handled by
|
|
// collectPositionalBindings. Dual-mode positional bindings
|
|
// (envelope: positional + alias/aliases) fall through so any
|
|
// user-supplied flag value wins over the positional arg.
|
|
primary := strings.TrimSpace(binding.FlagName)
|
|
alias := strings.TrimSpace(binding.Alias)
|
|
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
|
|
continue
|
|
}
|
|
}
|
|
primaryName := strings.TrimSpace(binding.FlagName)
|
|
if primaryName == "" {
|
|
continue
|
|
}
|
|
aliasName := strings.TrimSpace(binding.Alias)
|
|
|
|
// Candidate flag names in precedence order: primary, single alias,
|
|
// then extra aliases. Whichever is set first wins; mirrors the
|
|
// semantics of cmdutil.FlagOrFallback.
|
|
candidates := make([]string, 0, 2+len(binding.Aliases))
|
|
candidates = append(candidates, primaryName)
|
|
if aliasName != "" && aliasName != primaryName {
|
|
candidates = append(candidates, aliasName)
|
|
}
|
|
for _, extra := range binding.Aliases {
|
|
e := strings.TrimSpace(extra)
|
|
if e == "" || e == primaryName || e == aliasName {
|
|
continue
|
|
}
|
|
candidates = append(candidates, e)
|
|
}
|
|
|
|
flagName, anyChanged := firstChangedFlag(cmd, candidates...)
|
|
if !anyChanged {
|
|
flagName = primaryName
|
|
}
|
|
|
|
flag := cmd.Flags().Lookup(flagName)
|
|
if flag == nil {
|
|
continue
|
|
}
|
|
if binding.Required && !anyChanged && !binding.Positional {
|
|
if _, ok := existing[binding.Property]; ok {
|
|
continue
|
|
}
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
|
|
}
|
|
if !anyChanged {
|
|
continue
|
|
}
|
|
|
|
switch binding.Kind {
|
|
case ValueString:
|
|
value, err := cmd.Flags().GetString(flagName)
|
|
if err != nil {
|
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
|
}
|
|
params[binding.Property] = value
|
|
case ValueJSON:
|
|
value, err := cmd.Flags().GetString(flagName)
|
|
if err != nil {
|
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
|
}
|
|
var parsed any
|
|
if jsonErr := json.Unmarshal([]byte(value), &parsed); jsonErr != nil {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("invalid JSON for --%s: %v", flagName, jsonErr))
|
|
}
|
|
params[binding.Property] = parsed
|
|
case ValueInt:
|
|
value, err := cmd.Flags().GetInt(flagName)
|
|
if err != nil {
|
|
// Flag may be wrapped by typedValue (display-only override);
|
|
// fall back to parsing the raw string representation.
|
|
raw := flag.Value.String()
|
|
parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw))
|
|
if parseErr != nil {
|
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
|
}
|
|
params[binding.Property] = parsed
|
|
continue
|
|
}
|
|
params[binding.Property] = value
|
|
case ValueFloat:
|
|
value, err := cmd.Flags().GetFloat64(flagName)
|
|
if err != nil {
|
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
|
}
|
|
params[binding.Property] = value
|
|
case ValueBool:
|
|
value, err := cmd.Flags().GetBool(flagName)
|
|
if err != nil {
|
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
|
}
|
|
params[binding.Property] = value
|
|
case ValueStringSlice:
|
|
value, err := cmd.Flags().GetStringSlice(flagName)
|
|
if err != nil {
|
|
// Flag may be wrapped by typedValue (display-only override);
|
|
// fall back to reading the raw string and splitting by comma.
|
|
raw := strings.TrimSpace(flag.Value.String())
|
|
// pflag StringSlice wraps values in [brackets]
|
|
raw = strings.TrimPrefix(raw, "[")
|
|
raw = strings.TrimSuffix(raw, "]")
|
|
var parts []string
|
|
for _, s := range strings.Split(raw, ",") {
|
|
t := strings.TrimSpace(s)
|
|
// pflag StringSlice internally quotes each element
|
|
t = strings.Trim(t, "\"")
|
|
t = strings.TrimSpace(t)
|
|
if t != "" {
|
|
parts = append(parts, t)
|
|
}
|
|
}
|
|
params[binding.Property] = convert.StringsToAny(parts)
|
|
continue
|
|
}
|
|
params[binding.Property] = convert.StringsToAny(value)
|
|
case ValueIntSlice:
|
|
value, err := cmd.Flags().GetStringSlice(flagName)
|
|
if err != nil {
|
|
// Fallback: parse raw string
|
|
raw := strings.TrimSpace(flag.Value.String())
|
|
raw = strings.TrimPrefix(raw, "[")
|
|
raw = strings.TrimSuffix(raw, "]")
|
|
value = nil
|
|
for _, s := range strings.Split(raw, ",") {
|
|
if t := strings.TrimSpace(s); t != "" {
|
|
value = append(value, t)
|
|
}
|
|
}
|
|
}
|
|
parsed, parseErr := convert.ParseStringList(value, strconv.Atoi)
|
|
if parseErr != nil {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
|
}
|
|
params[binding.Property] = convert.IntsToAny(parsed)
|
|
case ValueFloatSlice:
|
|
value, err := cmd.Flags().GetStringSlice(flagName)
|
|
if err != nil {
|
|
// Fallback: parse raw string
|
|
raw := strings.TrimSpace(flag.Value.String())
|
|
raw = strings.TrimPrefix(raw, "[")
|
|
raw = strings.TrimSuffix(raw, "]")
|
|
value = nil
|
|
for _, s := range strings.Split(raw, ",") {
|
|
if t := strings.TrimSpace(s); t != "" {
|
|
value = append(value, t)
|
|
}
|
|
}
|
|
_ = err // clear error after fallback
|
|
}
|
|
parsed, parseErr := convert.ParseStringList(value, func(raw string) (float64, error) {
|
|
return strconv.ParseFloat(raw, 64)
|
|
})
|
|
if parseErr != nil {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
|
}
|
|
params[binding.Property] = convert.FloatsToAny(parsed)
|
|
case ValueBoolSlice:
|
|
value, err := cmd.Flags().GetStringSlice(flagName)
|
|
if err != nil {
|
|
// Fallback: parse raw string
|
|
raw := strings.TrimSpace(flag.Value.String())
|
|
raw = strings.TrimPrefix(raw, "[")
|
|
raw = strings.TrimSuffix(raw, "]")
|
|
value = nil
|
|
for _, s := range strings.Split(raw, ",") {
|
|
if t := strings.TrimSpace(s); t != "" {
|
|
value = append(value, t)
|
|
}
|
|
}
|
|
}
|
|
parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool)
|
|
if parseErr != nil {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
|
}
|
|
params[binding.Property] = convert.BoolsToAny(parsed)
|
|
}
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func MustString(params map[string]any, key string) (string, error) {
|
|
value, ok := params[key]
|
|
if !ok {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("%s is required", key))
|
|
}
|
|
raw, ok := value.(string)
|
|
if !ok || strings.TrimSpace(raw) == "" {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("%s must be a non-empty string", key))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func MoveParam(params map[string]any, from, to string) {
|
|
value, ok := params[from]
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(params, from)
|
|
params[to] = value
|
|
}
|
|
|
|
func buildFactories(factories []CommandFactory, runner executor.Runner) []*cobra.Command {
|
|
registryMu.Lock()
|
|
defer registryMu.Unlock()
|
|
|
|
out := make([]*cobra.Command, 0, len(factories))
|
|
for _, factory := range factories {
|
|
out = append(out, factory(runner))
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return out[i].Use < out[j].Use
|
|
})
|
|
return mergeRootCommands(out)
|
|
}
|
|
|
|
func mergeRootCommands(commands []*cobra.Command) []*cobra.Command {
|
|
byName := make(map[string]*cobra.Command, len(commands))
|
|
for _, cmd := range commands {
|
|
if cmd == nil || cmd.Name() == "" {
|
|
continue
|
|
}
|
|
if existing, ok := byName[cmd.Name()]; ok {
|
|
cobracmd.MergeCommandTree(existing, cmd)
|
|
continue
|
|
}
|
|
byName[cmd.Name()] = cmd
|
|
}
|
|
|
|
out := make([]*cobra.Command, 0, len(byName))
|
|
for _, cmd := range byName {
|
|
out = append(out, cmd)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Use < out[j].Use
|
|
})
|
|
return out
|
|
}
|
|
|
|
// requireYesForDelete enforces --yes confirmation for destructive operations.
|
|
// If the user has not passed --yes, the command is blocked (params["_blocked"] = true).
|
|
func requireYesForDelete(cmd *cobra.Command, params map[string]any) error {
|
|
yes, _ := cmd.Flags().GetBool("yes")
|
|
delete(params, "_yes")
|
|
if !yes {
|
|
params["_blocked"] = true
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compatFlagName converts a camelCase or snake_case parameter name to
|
|
// kebab-case suitable for CLI flags.
|
|
func compatFlagName(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
for idx, r := range raw {
|
|
switch {
|
|
case r == '_' || r == ' ' || r == '-':
|
|
if builder.Len() > 0 && !lastDash {
|
|
builder.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
case unicode.IsUpper(r):
|
|
if idx > 0 && !lastDash {
|
|
builder.WriteByte('-')
|
|
}
|
|
builder.WriteRune(unicode.ToLower(r))
|
|
lastDash = false
|
|
default:
|
|
builder.WriteRune(unicode.ToLower(r))
|
|
lastDash = false
|
|
}
|
|
}
|
|
return strings.Trim(builder.String(), "-")
|
|
}
|
|
|
|
// validateRequireTogether enforces "either all set, or all unset" semantics
|
|
// for each group of flag aliases. Returns a validation error pointing at the
|
|
// first failing group; nil if every group satisfies the check or no groups
|
|
// were declared. Unknown flag names in a group are skipped silently — the
|
|
// envelope load path already warned about them when applyFlagConstraints
|
|
// validated the same shape for MutuallyExclusive / RequireOneOf.
|
|
func validateRequireTogether(cmd *cobra.Command, groups [][]string) error {
|
|
for _, group := range groups {
|
|
set := make([]string, 0, len(group))
|
|
unset := make([]string, 0, len(group))
|
|
for _, raw := range group {
|
|
name := strings.TrimSpace(raw)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if cmd.Flags().Lookup(name) == nil {
|
|
continue
|
|
}
|
|
if cobracmd.FlagChanged(cmd, name) {
|
|
set = append(set, name)
|
|
} else {
|
|
unset = append(unset, name)
|
|
}
|
|
}
|
|
if len(set) == 0 || len(unset) == 0 {
|
|
continue
|
|
}
|
|
// Render a stable, human-readable list of the group's flag names so
|
|
// the error matches what the user typed. Example output:
|
|
// --start 和 --end 必须同时设置或同时不设置
|
|
return apperrors.NewValidation(fmt.Sprintf(
|
|
"--%s 和 --%s 必须同时设置或同时不设置",
|
|
strings.Join(set, " --"),
|
|
strings.Join(unset, " --"),
|
|
))
|
|
}
|
|
return nil
|
|
}
|