1047 lines
29 KiB
Go
1047 lines
29 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 app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
|
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/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
type pluginFlagKind uint8
|
|
|
|
const (
|
|
pluginFlagString pluginFlagKind = iota
|
|
pluginFlagInt
|
|
pluginFlagFloat
|
|
pluginFlagBool
|
|
pluginFlagStringSlice
|
|
pluginFlagJSON
|
|
)
|
|
|
|
type pluginFlagBinding struct {
|
|
property string
|
|
names []string
|
|
kind pluginFlagKind
|
|
required bool
|
|
defaultProvided bool
|
|
defaultValue string
|
|
envDefault string
|
|
positional bool
|
|
positionalIndex int
|
|
omitWhen string
|
|
}
|
|
|
|
type pluginFlagReservations struct {
|
|
names map[string]bool
|
|
shorthands map[string]bool
|
|
}
|
|
|
|
// buildPluginCommands compiles only manifest-authored CLI overlays. It never
|
|
// starts a transport or calls tools/list, so plugin help stays available when
|
|
// a local subprocess or remote MCP endpoint is offline.
|
|
func buildPluginCommands(
|
|
servers []mcptypes.ServerDescriptor,
|
|
runner executor.Runner,
|
|
hostRoot *cobra.Command,
|
|
) []*cobra.Command {
|
|
reservations := pluginReservedFlags(hostRoot)
|
|
sorted := append([]mcptypes.ServerDescriptor(nil), servers...)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
left := strings.TrimSpace(sorted[i].DisplayName) + "\x00" + strings.TrimSpace(sorted[i].Key)
|
|
right := strings.TrimSpace(sorted[j].DisplayName) + "\x00" + strings.TrimSpace(sorted[j].Key)
|
|
return left < right
|
|
})
|
|
|
|
roots := make(map[string]*cobra.Command)
|
|
for _, server := range sorted {
|
|
overlay := server.CLI
|
|
if overlay.Skip || len(overlay.ToolOverrides) == 0 {
|
|
continue
|
|
}
|
|
if reason := unsupportedPluginOverlay(overlay); reason != "" {
|
|
slog.Warn("plugin: unsupported overlay semantics, skipping command tree",
|
|
"plugin", server.DisplayName, "field", reason)
|
|
continue
|
|
}
|
|
name := firstNonEmptyPluginString(overlay.Command, overlay.ID, server.Key)
|
|
if !validPluginCommandName(name) {
|
|
slog.Warn("plugin: invalid overlay command name, skipping",
|
|
"plugin", server.DisplayName, "command", name)
|
|
continue
|
|
}
|
|
|
|
root := buildPluginOverlayRoot(server, runner, reservations)
|
|
if root == nil {
|
|
continue
|
|
}
|
|
if existing := roots[name]; existing != nil {
|
|
mergePluginRoot(existing, root)
|
|
continue
|
|
}
|
|
roots[name] = root
|
|
}
|
|
|
|
names := make([]string, 0, len(roots))
|
|
for name := range roots {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
commands := make([]*cobra.Command, 0, len(names))
|
|
for _, name := range names {
|
|
commands = append(commands, roots[name])
|
|
}
|
|
return commands
|
|
}
|
|
|
|
func buildPluginOverlayRoot(
|
|
server mcptypes.ServerDescriptor,
|
|
runner executor.Runner,
|
|
reservations pluginFlagReservations,
|
|
) *cobra.Command {
|
|
overlay := server.CLI
|
|
name := firstNonEmptyPluginString(overlay.Command, overlay.ID, server.Key)
|
|
short := firstNonEmptyPluginString(overlay.Description, server.Description, server.DisplayName, name)
|
|
root := cobracmd.NewGroupCommand(name, short)
|
|
root.Hidden = overlay.Hidden
|
|
root.Aliases = safePluginAliases(overlay.Aliases, name)
|
|
cmdutil.MarkPluginSource(root)
|
|
|
|
groups := make(map[string]*cobra.Command)
|
|
groupNames := make([]string, 0, len(overlay.Groups))
|
|
for groupName := range overlay.Groups {
|
|
groupNames = append(groupNames, groupName)
|
|
}
|
|
sort.Strings(groupNames)
|
|
for _, groupName := range groupNames {
|
|
group := overlay.Groups[groupName]
|
|
ensurePluginGroup(root, groupName, group.Description, groups)
|
|
}
|
|
|
|
toolNames := make([]string, 0, len(overlay.ToolOverrides))
|
|
for toolName := range overlay.ToolOverrides {
|
|
toolNames = append(toolNames, toolName)
|
|
}
|
|
sort.Strings(toolNames)
|
|
leafCount := 0
|
|
for _, toolName := range toolNames {
|
|
override := overlay.ToolOverrides[toolName]
|
|
if override.Hidden || strings.TrimSpace(toolName) == "" {
|
|
continue
|
|
}
|
|
leaf := buildPluginLeaf(server, toolName, override, runner, reservations)
|
|
if leaf == nil {
|
|
continue
|
|
}
|
|
parent := root
|
|
if groupPath := strings.TrimSpace(override.Group); groupPath != "" {
|
|
parent = ensurePluginGroup(root, groupPath, groupPath, groups)
|
|
if parent == nil {
|
|
slog.Warn("plugin: invalid overlay group path, skipping leaf",
|
|
"plugin", server.DisplayName, "tool", toolName, "group", groupPath)
|
|
continue
|
|
}
|
|
}
|
|
if existing := cobracmd.ChildByName(parent, leaf.Name()); existing != nil {
|
|
slog.Warn("plugin: duplicate overlay leaf, keeping first",
|
|
"plugin", server.DisplayName,
|
|
"command", strings.TrimSpace(parent.CommandPath()+" "+leaf.Name()))
|
|
continue
|
|
}
|
|
parent.AddCommand(leaf)
|
|
leafCount++
|
|
}
|
|
|
|
if leafCount == 0 {
|
|
return nil
|
|
}
|
|
pruneEmptyPluginGroups(root)
|
|
return root
|
|
}
|
|
|
|
func buildPluginLeaf(
|
|
server mcptypes.ServerDescriptor,
|
|
toolName string,
|
|
override mcptypes.CLIToolOverride,
|
|
runner executor.Runner,
|
|
reservations pluginFlagReservations,
|
|
) *cobra.Command {
|
|
overlay := server.CLI
|
|
if reason := unsupportedPluginToolOverride(override); reason != "" {
|
|
slog.Warn("plugin: unsupported tool override semantics, skipping leaf",
|
|
"plugin", server.DisplayName, "tool", toolName, "field", reason)
|
|
return nil
|
|
}
|
|
name := strings.TrimSpace(override.CLIName)
|
|
if name == "" {
|
|
name = derivePluginCommandName(toolName, overlay.Prefixes)
|
|
}
|
|
if !validPluginCommandName(name) {
|
|
slog.Warn("plugin: invalid overlay leaf name, skipping",
|
|
"plugin", server.DisplayName, "tool", toolName, "command", name)
|
|
return nil
|
|
}
|
|
|
|
short := firstNonEmptyPluginString(override.Description, toolName)
|
|
bindings, use, argsValidator, ok := registerPluginBindings(name, override, reservations)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !validPluginFlagConstraints(bindings, override) {
|
|
slog.Warn("plugin: invalid flag constraints, skipping leaf",
|
|
"plugin", server.DisplayName, "tool", toolName)
|
|
return nil
|
|
}
|
|
canonicalProduct := firstNonEmptyPluginString(overlay.ID, server.Key)
|
|
cmd := &cobra.Command{
|
|
Use: use,
|
|
Short: short,
|
|
Example: strings.TrimRight(override.Example, " \t\r\n"),
|
|
Args: argsValidator,
|
|
DisableAutoGenTag: true,
|
|
}
|
|
cmdutil.MarkPluginSource(cmd)
|
|
|
|
registerPluginFlags(cmd, bindings, override, reservations)
|
|
applyPluginFlagConstraints(cmd, override)
|
|
cmd.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")
|
|
}
|
|
params, err := executor.MergePayloads(jsonPayload, paramsPayload, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := collectPluginBindings(cmd, args, bindings, params); err != nil {
|
|
return err
|
|
}
|
|
if wrapper := strings.TrimSpace(override.BodyWrapper); wrapper != "" {
|
|
wrapPluginParams(params, wrapper)
|
|
}
|
|
|
|
dryRun := pluginRootBoolFlag(cmd, "dry-run")
|
|
if override.IsSensitive && !dryRun && !pluginRootBoolFlag(cmd, "yes") {
|
|
return pluginConfirmationRequired(cmd.CommandPath())
|
|
}
|
|
if runner == nil {
|
|
return apperrors.NewInternal("plugin command runner is not configured")
|
|
}
|
|
|
|
invocation := executor.NewCompatibilityInvocation(
|
|
cobracmd.LegacyCommandPath(cmd),
|
|
canonicalProduct,
|
|
toolName,
|
|
params,
|
|
)
|
|
invocation.DryRun = dryRun
|
|
result, err := runner.Run(cmd.Context(), invocation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func registerPluginBindings(
|
|
name string,
|
|
override mcptypes.CLIToolOverride,
|
|
reservations pluginFlagReservations,
|
|
) ([]pluginFlagBinding, string, cobra.PositionalArgs, bool) {
|
|
properties := make([]string, 0, len(override.Flags))
|
|
for property := range override.Flags {
|
|
properties = append(properties, property)
|
|
}
|
|
sort.Strings(properties)
|
|
|
|
bindings := make([]pluginFlagBinding, 0, len(properties))
|
|
usedNames := make(map[string]bool, len(reservations.names))
|
|
for reserved := range reservations.names {
|
|
usedNames[reserved] = true
|
|
}
|
|
usedPositions := make(map[int]bool)
|
|
maxPositionals := 0
|
|
minPositionals := 0
|
|
type positionalLabel struct {
|
|
index int
|
|
name string
|
|
}
|
|
var labels []positionalLabel
|
|
|
|
for _, property := range properties {
|
|
flagOverride := override.Flags[property]
|
|
if strings.Contains(property, ".") {
|
|
slog.Warn("plugin: dotted flag properties are unsupported, skipping leaf",
|
|
"command", name, "property", property)
|
|
return nil, "", nil, false
|
|
}
|
|
if reason := unsupportedPluginFlagOverride(flagOverride); reason != "" {
|
|
slog.Warn("plugin: unsupported flag override semantics, skipping leaf",
|
|
"command", name, "property", property, "field", reason)
|
|
return nil, "", nil, false
|
|
}
|
|
primary := strings.TrimSpace(flagOverride.Alias)
|
|
if primary == "" && !flagOverride.Positional {
|
|
primary = pluginKebabName(property)
|
|
}
|
|
if !flagOverride.Positional &&
|
|
(!validPluginFlagName(primary) || usedNames[primary]) {
|
|
slog.Warn("plugin: primary overlay flag conflicts with the host CLI, skipping leaf",
|
|
"command", name, "flag", primary, "property", property)
|
|
return nil, "", nil, false
|
|
}
|
|
|
|
names := make([]string, 0, 1+len(flagOverride.Aliases))
|
|
for _, candidate := range append([]string{primary}, flagOverride.Aliases...) {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if !validPluginFlagName(candidate) || usedNames[candidate] {
|
|
if candidate != "" {
|
|
slog.Warn("plugin: duplicate or invalid overlay flag, skipping",
|
|
"command", name, "flag", candidate, "property", property)
|
|
}
|
|
continue
|
|
}
|
|
usedNames[candidate] = true
|
|
names = append(names, candidate)
|
|
}
|
|
|
|
if flagOverride.Positional {
|
|
index := flagOverride.PositionalIndex
|
|
if index < 0 || usedPositions[index] {
|
|
slog.Warn("plugin: invalid or duplicate positional binding, skipping leaf",
|
|
"command", name, "property", property, "index", index)
|
|
return nil, "", nil, false
|
|
}
|
|
usedPositions[index] = true
|
|
if index+1 > maxPositionals {
|
|
maxPositionals = index + 1
|
|
}
|
|
if flagOverride.Required && len(names) == 0 && index+1 > minPositionals {
|
|
minPositionals = index + 1
|
|
}
|
|
labels = append(labels, positionalLabel{index: index, name: property})
|
|
}
|
|
|
|
bindings = append(bindings, pluginFlagBinding{
|
|
property: property,
|
|
names: names,
|
|
kind: pluginFlagKindFromString(flagOverride.Type),
|
|
required: flagOverride.Required,
|
|
defaultProvided: flagOverride.Default != "",
|
|
defaultValue: flagOverride.Default,
|
|
envDefault: strings.TrimSpace(flagOverride.EnvDefault),
|
|
positional: flagOverride.Positional,
|
|
positionalIndex: flagOverride.PositionalIndex,
|
|
omitWhen: strings.ToLower(strings.TrimSpace(flagOverride.OmitWhen)),
|
|
})
|
|
}
|
|
for index := 0; index < maxPositionals; index++ {
|
|
if !usedPositions[index] {
|
|
slog.Warn("plugin: positional bindings must use contiguous indexes, skipping leaf",
|
|
"command", name, "missingIndex", index)
|
|
return nil, "", nil, false
|
|
}
|
|
}
|
|
|
|
use := name
|
|
sort.SliceStable(labels, func(i, j int) bool { return labels[i].index < labels[j].index })
|
|
for _, label := range labels {
|
|
use += " [" + label.name + "]"
|
|
}
|
|
|
|
var validator cobra.PositionalArgs = cobra.NoArgs
|
|
switch {
|
|
case maxPositionals == 0:
|
|
validator = cobra.NoArgs
|
|
case minPositionals == maxPositionals:
|
|
validator = cobra.ExactArgs(maxPositionals)
|
|
case minPositionals > 0:
|
|
validator = cobra.RangeArgs(minPositionals, maxPositionals)
|
|
default:
|
|
validator = cobra.MaximumNArgs(maxPositionals)
|
|
}
|
|
return bindings, use, validator, true
|
|
}
|
|
|
|
func registerPluginFlags(
|
|
cmd *cobra.Command,
|
|
bindings []pluginFlagBinding,
|
|
override mcptypes.CLIToolOverride,
|
|
reservations pluginFlagReservations,
|
|
) {
|
|
usedShorthands := make(map[string]bool)
|
|
for _, binding := range bindings {
|
|
flagOverride := override.Flags[binding.property]
|
|
for index, name := range binding.names {
|
|
shorthand := ""
|
|
if index == 0 {
|
|
shorthand = safePluginShorthand(
|
|
flagOverride.Shorthand,
|
|
usedShorthands,
|
|
reservations.shorthands,
|
|
)
|
|
}
|
|
usage := firstNonEmptyPluginString(flagOverride.Description, binding.property)
|
|
registerPluginFlag(cmd.Flags(), name, shorthand, usage, binding.kind, flagOverride.Default)
|
|
if index > 0 || flagOverride.Hidden {
|
|
_ = cmd.Flags().MarkHidden(name)
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
|
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
|
_ = cmd.Flags().MarkHidden("json")
|
|
_ = cmd.Flags().MarkHidden("params")
|
|
}
|
|
|
|
func registerPluginFlag(flags *pflag.FlagSet, name, shorthand, usage string, kind pluginFlagKind, rawDefault string) {
|
|
switch kind {
|
|
case pluginFlagInt:
|
|
value, _ := strconv.Atoi(strings.TrimSpace(rawDefault))
|
|
flags.IntP(name, shorthand, value, usage)
|
|
case pluginFlagFloat:
|
|
value, _ := strconv.ParseFloat(strings.TrimSpace(rawDefault), 64)
|
|
flags.Float64P(name, shorthand, value, usage)
|
|
case pluginFlagBool:
|
|
value, _ := strconv.ParseBool(strings.TrimSpace(rawDefault))
|
|
flags.BoolP(name, shorthand, value, usage)
|
|
case pluginFlagStringSlice:
|
|
var value []string
|
|
if strings.TrimSpace(rawDefault) != "" {
|
|
for _, item := range strings.Split(rawDefault, ",") {
|
|
if item = strings.TrimSpace(item); item != "" {
|
|
value = append(value, item)
|
|
}
|
|
}
|
|
}
|
|
flags.StringSliceP(name, shorthand, value, usage)
|
|
default:
|
|
flags.StringP(name, shorthand, rawDefault, usage)
|
|
}
|
|
}
|
|
|
|
func collectPluginBindings(
|
|
cmd *cobra.Command,
|
|
args []string,
|
|
bindings []pluginFlagBinding,
|
|
params map[string]any,
|
|
) error {
|
|
for _, binding := range bindings {
|
|
var (
|
|
value any
|
|
set bool
|
|
err error
|
|
)
|
|
for _, name := range binding.names {
|
|
if flag := cmd.Flags().Lookup(name); flag != nil && flag.Changed {
|
|
value, err = readPluginFlag(cmd.Flags(), name, binding.kind)
|
|
set = err == nil
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid plugin flag for %s: %v", binding.property, err))
|
|
}
|
|
if !set {
|
|
if existing, exists := params[binding.property]; exists {
|
|
value = existing
|
|
set = true
|
|
}
|
|
}
|
|
if !set && binding.positional && binding.positionalIndex >= 0 && binding.positionalIndex < len(args) {
|
|
value, err = parsePluginValue(args[binding.positionalIndex], binding.kind)
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf(
|
|
"invalid positional value for %s: %v",
|
|
binding.property,
|
|
err,
|
|
))
|
|
}
|
|
set = true
|
|
}
|
|
if !set && binding.defaultProvided {
|
|
value, err = parsePluginValue(binding.defaultValue, binding.kind)
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid default for %s: %v", binding.property, err))
|
|
}
|
|
set = true
|
|
}
|
|
if !set && binding.envDefault != "" {
|
|
if raw := strings.TrimSpace(os.Getenv(binding.envDefault)); raw != "" {
|
|
value, err = parsePluginValue(raw, binding.kind)
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid %s value from %s: %v", binding.property, binding.envDefault, err))
|
|
}
|
|
set = true
|
|
}
|
|
}
|
|
if !set {
|
|
if binding.required {
|
|
label := binding.property
|
|
if len(binding.names) > 0 {
|
|
label = "--" + binding.names[0]
|
|
}
|
|
return apperrors.NewValidation("missing required plugin parameter: " + label)
|
|
}
|
|
continue
|
|
}
|
|
if shouldOmitPluginValue(value, binding.omitWhen) {
|
|
delete(params, binding.property)
|
|
if binding.required {
|
|
return apperrors.NewValidation("required plugin parameter is empty: " + binding.property)
|
|
}
|
|
continue
|
|
}
|
|
params[binding.property] = value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readPluginFlag(flags *pflag.FlagSet, name string, kind pluginFlagKind) (any, error) {
|
|
switch kind {
|
|
case pluginFlagInt:
|
|
return flags.GetInt(name)
|
|
case pluginFlagFloat:
|
|
return flags.GetFloat64(name)
|
|
case pluginFlagBool:
|
|
return flags.GetBool(name)
|
|
case pluginFlagStringSlice:
|
|
return flags.GetStringSlice(name)
|
|
case pluginFlagJSON:
|
|
raw, err := flags.GetString(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
|
return nil, err
|
|
}
|
|
return value, nil
|
|
default:
|
|
return flags.GetString(name)
|
|
}
|
|
}
|
|
|
|
func parsePluginValue(raw string, kind pluginFlagKind) (any, error) {
|
|
switch kind {
|
|
case pluginFlagInt:
|
|
return strconv.Atoi(strings.TrimSpace(raw))
|
|
case pluginFlagFloat:
|
|
return strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
|
case pluginFlagBool:
|
|
return strconv.ParseBool(strings.TrimSpace(raw))
|
|
case pluginFlagStringSlice:
|
|
var values []string
|
|
for _, item := range strings.Split(raw, ",") {
|
|
if item = strings.TrimSpace(item); item != "" {
|
|
values = append(values, item)
|
|
}
|
|
}
|
|
return values, nil
|
|
case pluginFlagJSON:
|
|
var value any
|
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
|
return nil, err
|
|
}
|
|
return value, nil
|
|
default:
|
|
return raw, nil
|
|
}
|
|
}
|
|
|
|
func shouldOmitPluginValue(value any, mode string) bool {
|
|
if mode == "never" {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return strings.TrimSpace(typed) == ""
|
|
case []string:
|
|
return len(typed) == 0
|
|
}
|
|
if mode != "zero" {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return !typed
|
|
case int:
|
|
return typed == 0
|
|
case float64:
|
|
return typed == 0
|
|
case []any:
|
|
return len(typed) == 0
|
|
case map[string]any:
|
|
return len(typed) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func wrapPluginParams(params map[string]any, wrapper string) {
|
|
if params == nil || strings.TrimSpace(wrapper) == "" {
|
|
return
|
|
}
|
|
body := make(map[string]any)
|
|
if existing, ok := params[wrapper].(map[string]any); ok {
|
|
for key, value := range existing {
|
|
body[key] = value
|
|
}
|
|
delete(params, wrapper)
|
|
}
|
|
for key, value := range params {
|
|
if strings.HasPrefix(key, "_") {
|
|
continue
|
|
}
|
|
body[key] = value
|
|
delete(params, key)
|
|
}
|
|
params[wrapper] = body
|
|
}
|
|
|
|
func applyPluginFlagConstraints(cmd *cobra.Command, override mcptypes.CLIToolOverride) {
|
|
validate := func(group []string) []string {
|
|
names := make([]string, 0, len(group))
|
|
for _, name := range group {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || cmd.Flags().Lookup(name) == nil {
|
|
return nil
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
for _, group := range override.MutuallyExclusive {
|
|
if names := validate(group); len(names) > 1 {
|
|
cmd.MarkFlagsMutuallyExclusive(names...)
|
|
}
|
|
}
|
|
for _, group := range override.RequireOneOf {
|
|
if names := validate(group); len(names) > 0 {
|
|
cmd.MarkFlagsOneRequired(names...)
|
|
}
|
|
}
|
|
for _, group := range override.RequireTogether {
|
|
if names := validate(group); len(names) > 1 {
|
|
cmd.MarkFlagsRequiredTogether(names...)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validPluginFlagConstraints(bindings []pluginFlagBinding, override mcptypes.CLIToolOverride) bool {
|
|
known := make(map[string]bool)
|
|
for _, binding := range bindings {
|
|
for _, name := range binding.names {
|
|
known[name] = true
|
|
}
|
|
}
|
|
validate := func(groups [][]string, minimum int) bool {
|
|
for _, group := range groups {
|
|
if len(group) < minimum {
|
|
return false
|
|
}
|
|
seen := make(map[string]bool, len(group))
|
|
for _, rawName := range group {
|
|
name := strings.TrimSpace(rawName)
|
|
if name == "" || !known[name] || seen[name] {
|
|
return false
|
|
}
|
|
seen[name] = true
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return validate(override.MutuallyExclusive, 2) &&
|
|
validate(override.RequireOneOf, 1) &&
|
|
validate(override.RequireTogether, 2)
|
|
}
|
|
|
|
func ensurePluginGroup(
|
|
root *cobra.Command,
|
|
rawPath string,
|
|
description string,
|
|
groups map[string]*cobra.Command,
|
|
) *cobra.Command {
|
|
if !validPluginGroupPath(rawPath) {
|
|
return nil
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(rawPath), ".")
|
|
parent := root
|
|
var pathParts []string
|
|
for index, rawPart := range parts {
|
|
part := strings.TrimSpace(rawPart)
|
|
pathParts = append(pathParts, part)
|
|
path := strings.Join(pathParts, ".")
|
|
if existing := groups[path]; existing != nil {
|
|
parent = existing
|
|
continue
|
|
}
|
|
short := part
|
|
if index == len(parts)-1 {
|
|
short = firstNonEmptyPluginString(description, part)
|
|
}
|
|
group := cobracmd.NewGroupCommand(part, short)
|
|
cmdutil.MarkPluginSource(group)
|
|
parent.AddCommand(group)
|
|
groups[path] = group
|
|
parent = group
|
|
}
|
|
return parent
|
|
}
|
|
|
|
func validPluginGroupPath(rawPath string) bool {
|
|
parts := strings.Split(strings.TrimSpace(rawPath), ".")
|
|
for _, rawPart := range parts {
|
|
if !validPluginCommandName(strings.TrimSpace(rawPart)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func mergePluginRoot(destination, source *cobra.Command) {
|
|
if destination == nil || source == nil {
|
|
return
|
|
}
|
|
aliases := append(append([]string(nil), destination.Aliases...), source.Aliases...)
|
|
destination.Aliases = safePluginAliases(aliases, destination.Name())
|
|
cobracmd.MergeCommandTree(destination, source)
|
|
}
|
|
|
|
func pruneEmptyPluginGroups(parent *cobra.Command) {
|
|
if parent == nil {
|
|
return
|
|
}
|
|
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
|
|
pruneEmptyPluginGroups(child)
|
|
_, group, err := corecmd.GroupPolicyFor(child)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
|
|
}
|
|
if group && len(child.Commands()) == 0 {
|
|
parent.RemoveCommand(child)
|
|
}
|
|
}
|
|
}
|
|
|
|
func pluginConfirmationRequired(commandPath string) error {
|
|
return apperrors.NewValidation(
|
|
commandPath+" is sensitive; preview with --dry-run, then obtain user confirmation and retry with --yes",
|
|
apperrors.WithReason("confirmation_required"),
|
|
apperrors.WithHint("run the same command with --dry-run first; after the user confirms, add --yes"),
|
|
apperrors.WithActions("preview with --dry-run", "obtain user confirmation", "execute with --yes"),
|
|
)
|
|
}
|
|
|
|
func pluginRootBoolFlag(cmd *cobra.Command, name string) bool {
|
|
if cmd == nil || cmd.Root() == nil {
|
|
return false
|
|
}
|
|
flags := cmd.Root().PersistentFlags()
|
|
if flags == nil || flags.Lookup(name) == nil {
|
|
return false
|
|
}
|
|
value, err := flags.GetBool(name)
|
|
return err == nil && value
|
|
}
|
|
|
|
func pluginFlagKindFromString(raw string) pluginFlagKind {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "int", "integer":
|
|
return pluginFlagInt
|
|
case "float", "float64", "number":
|
|
return pluginFlagFloat
|
|
case "bool", "boolean":
|
|
return pluginFlagBool
|
|
case "stringslice", "string_slice", "array", "[]string":
|
|
return pluginFlagStringSlice
|
|
case "json", "object":
|
|
return pluginFlagJSON
|
|
default:
|
|
return pluginFlagString
|
|
}
|
|
}
|
|
|
|
func safePluginShorthand(raw string, used, reserved map[string]bool) string {
|
|
value := strings.TrimSpace(raw)
|
|
if len(value) != 1 || used[value] || reserved[value] {
|
|
return ""
|
|
}
|
|
used[value] = true
|
|
return value
|
|
}
|
|
|
|
func pluginReservedFlags(root *cobra.Command) pluginFlagReservations {
|
|
reservations := pluginFlagReservations{
|
|
names: map[string]bool{
|
|
"client-id": true, "client-secret": true, "debug": true,
|
|
"dry-run": true, "fields": true, "format": true, "help": true,
|
|
"jq": true, "json": true, "mock": true, "output": true,
|
|
"params": true, "profile": true, "timeout": true, "token": true,
|
|
"verbose": true, "yes": true,
|
|
},
|
|
shorthands: map[string]bool{
|
|
"f": true, "h": true, "o": true, "v": true, "y": true,
|
|
},
|
|
}
|
|
if root == nil {
|
|
return reservations
|
|
}
|
|
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
|
reservations.names[flag.Name] = true
|
|
if shorthand := strings.TrimSpace(flag.Shorthand); shorthand != "" {
|
|
reservations.shorthands[shorthand] = true
|
|
}
|
|
})
|
|
return reservations
|
|
}
|
|
|
|
func unsupportedPluginOverlay(overlay mcptypes.CLIOverlay) string {
|
|
switch {
|
|
case strings.TrimSpace(overlay.Parent) != "":
|
|
return "parent"
|
|
case strings.TrimSpace(overlay.Group) != "":
|
|
return "group"
|
|
case len(overlay.ServerDeps) > 0:
|
|
return "serverDeps"
|
|
case len(overlay.Hints) > 0:
|
|
return "hintCommands"
|
|
case strings.TrimSpace(overlay.RedirectTo) != "":
|
|
return "redirectTo"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func unsupportedPluginDescriptor(root *cobra.Command, descriptor mcptypes.ServerDescriptor) string {
|
|
overlay := descriptor.CLI
|
|
if reason := unsupportedPluginOverlay(overlay); reason != "" {
|
|
return reason
|
|
}
|
|
if len(overlay.ToolOverrides) == 0 {
|
|
return ""
|
|
}
|
|
if !validPluginCommandName(pluginDescriptorRootName(descriptor)) {
|
|
return "command"
|
|
}
|
|
for groupPath := range overlay.Groups {
|
|
if !validPluginGroupPath(groupPath) {
|
|
return "groups"
|
|
}
|
|
}
|
|
reservations := pluginReservedFlags(root)
|
|
for toolName, override := range overlay.ToolOverrides {
|
|
if override.Hidden {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(toolName) == "" {
|
|
return "tool"
|
|
}
|
|
if reason := unsupportedPluginToolOverride(override); reason != "" {
|
|
return reason
|
|
}
|
|
leafName := strings.TrimSpace(override.CLIName)
|
|
if leafName == "" {
|
|
leafName = derivePluginCommandName(toolName, overlay.Prefixes)
|
|
}
|
|
if !validPluginCommandName(leafName) {
|
|
return "cliName"
|
|
}
|
|
if groupPath := strings.TrimSpace(override.Group); groupPath != "" &&
|
|
!validPluginGroupPath(groupPath) {
|
|
return "group"
|
|
}
|
|
bindings, _, _, ok := registerPluginBindings(leafName, override, reservations)
|
|
if !ok {
|
|
return "flags"
|
|
}
|
|
if !validPluginFlagConstraints(bindings, override) {
|
|
return "constraints"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func unsupportedPluginToolOverride(override mcptypes.CLIToolOverride) string {
|
|
switch {
|
|
case len(override.CLIAliases) > 0:
|
|
return "cliAliases"
|
|
case len(override.OutputFormat) > 0:
|
|
return "outputFormat"
|
|
case strings.TrimSpace(override.ServerOverride) != "":
|
|
return "serverOverride"
|
|
case strings.TrimSpace(override.RedirectTo) != "":
|
|
return "redirectTo"
|
|
case len(override.Pipeline) > 0:
|
|
return "pipeline"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func unsupportedPluginFlagOverride(override mcptypes.CLIFlagOverride) string {
|
|
switch {
|
|
case strings.TrimSpace(override.MapsTo) != "":
|
|
return "mapsTo"
|
|
case strings.TrimSpace(override.Transform) != "":
|
|
return "transform"
|
|
case len(override.TransformArgs) > 0:
|
|
return "transformArgs"
|
|
case strings.TrimSpace(override.RuntimeDefault) != "":
|
|
return "runtimeDefault"
|
|
case override.PipelineLocal:
|
|
return "pipelineLocal"
|
|
case !supportedPluginFlagType(override.Type):
|
|
return "type"
|
|
case !supportedPluginOmitMode(override.OmitWhen):
|
|
return "omitWhen"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func supportedPluginFlagType(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "string", "int", "integer", "float", "float64", "number",
|
|
"bool", "boolean", "stringslice", "string_slice", "array", "[]string",
|
|
"json", "object":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func supportedPluginOmitMode(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "empty", "zero", "never":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func safePluginAliases(rawAliases []string, commandName string) []string {
|
|
seen := map[string]bool{commandName: true}
|
|
aliases := make([]string, 0, len(rawAliases))
|
|
for _, raw := range rawAliases {
|
|
alias := strings.TrimSpace(raw)
|
|
if !validPluginCommandName(alias) || reservedCommands[alias] || seen[alias] {
|
|
continue
|
|
}
|
|
seen[alias] = true
|
|
aliases = append(aliases, alias)
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
func derivePluginCommandName(toolName string, prefixes []string) string {
|
|
name := strings.TrimSpace(toolName)
|
|
for _, rawPrefix := range prefixes {
|
|
prefix := strings.TrimSpace(rawPrefix)
|
|
if prefix != "" && strings.HasPrefix(name, prefix+"_") {
|
|
name = strings.TrimPrefix(name, prefix+"_")
|
|
break
|
|
}
|
|
}
|
|
return pluginKebabName(name)
|
|
}
|
|
|
|
func pluginKebabName(raw string) string {
|
|
var result strings.Builder
|
|
lastDash := false
|
|
var previous rune
|
|
for index, current := range raw {
|
|
separator := current == '_' || current == '.' || unicode.IsSpace(current)
|
|
if separator {
|
|
if result.Len() > 0 && !lastDash {
|
|
result.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
previous = current
|
|
continue
|
|
}
|
|
if unicode.IsUpper(current) && index > 0 && !lastDash &&
|
|
(unicode.IsLower(previous) || unicode.IsDigit(previous)) {
|
|
result.WriteByte('-')
|
|
}
|
|
result.WriteRune(unicode.ToLower(current))
|
|
lastDash = false
|
|
previous = current
|
|
}
|
|
return strings.Trim(result.String(), "-")
|
|
}
|
|
|
|
func validPluginCommandName(name string) bool {
|
|
name = strings.TrimSpace(name)
|
|
return name != "help" && validPluginKebabName(name)
|
|
}
|
|
|
|
func validPluginFlagName(name string) bool {
|
|
name = strings.TrimSpace(name)
|
|
return name != "json" && name != "params" && validPluginKebabName(name)
|
|
}
|
|
|
|
func validPluginKebabName(name string) bool {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name[0] < 'a' || name[0] > 'z' {
|
|
return false
|
|
}
|
|
previousDash := false
|
|
for _, current := range name {
|
|
switch {
|
|
case current >= 'a' && current <= 'z':
|
|
previousDash = false
|
|
case current >= '0' && current <= '9':
|
|
previousDash = false
|
|
case current == '-' && !previousDash:
|
|
previousDash = true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return !previousDash
|
|
}
|
|
|
|
func firstNonEmptyPluginString(values ...string) string {
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|