// 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 [] 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. 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 { // Shape the MCP content payload (the actual data), not the // {endpoint, content} runtime envelope, so rename/drop/table // paths resolve against response fields (e.g. result.items) // rather than the wrapper. Falls back to the whole Response // when no content map is present (degraded/echo paths). if content, ok := result.Response["content"].(map[string]any); ok { result.Response["content"] = route.OutputTransform(content) } else { 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, "profile": 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("missing required flag: --%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 }