Files
dingtalk-workspace-cli/internal/compat/registry.go
T
修雨 838e5453d8 fix(compat): guard envelope-driven flag registration against pflag panics (#449)
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.
2026-06-10 15:31:37 +08:00

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
}