- Build tool → endpoint mapping from CLI tools and overrides - directRuntimeEndpoint resolves by tool name before product fallback - Support dynamic command routing with compat layer updates
614 lines
17 KiB
Go
614 lines
17 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// BuildDynamicCommands generates cobra commands from servers.json CLIOverlay metadata.
|
|
// Each server with non-skip CLIOverlay gets a top-level command with groups and
|
|
// tool overrides translated into subcommands with proper flag bindings and transforms.
|
|
//
|
|
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
|
|
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
|
|
//
|
|
// Conversion rules reference: docs/mcp-to-cli-conversion.md
|
|
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
|
|
type builtCmd struct {
|
|
cmd *cobra.Command
|
|
parent string // cli.Parent: attach as sub-command of this top-level command
|
|
}
|
|
|
|
var built []builtCmd
|
|
for _, server := range servers {
|
|
cli := server.CLI
|
|
// §1.5: cli.skip → skip entire service
|
|
if cli.Skip {
|
|
continue
|
|
}
|
|
if len(cli.ToolOverrides) == 0 {
|
|
continue
|
|
}
|
|
|
|
// §1.1: cli.command → top-level command name
|
|
cmdName := strings.TrimSpace(cli.Command)
|
|
if cmdName == "" {
|
|
cmdName = strings.TrimSpace(cli.ID)
|
|
}
|
|
if cmdName == "" {
|
|
continue
|
|
}
|
|
|
|
rootCmd := NewGroupCommand(cmdName, cli.Description)
|
|
// §1.5: cli.hidden → entire service hidden
|
|
if cli.Hidden {
|
|
rootCmd.Hidden = true
|
|
}
|
|
|
|
// Build detail index for this server: toolName → DetailTool
|
|
detailIndex := buildDetailIndex(detailsByID[strings.TrimSpace(cli.ID)])
|
|
|
|
// §1.2: Build group sub-commands (including nested groups via "." separator)
|
|
groupCmds := make(map[string]*cobra.Command)
|
|
if len(cli.Groups) > 0 {
|
|
groupNames := sortedKeys(cli.Groups)
|
|
for _, groupName := range groupNames {
|
|
groupDef := cli.Groups[groupName]
|
|
ensureNestedGroup(rootCmd, groupName, groupDef.Description, groupCmds)
|
|
}
|
|
}
|
|
|
|
// §1.3: Build tool override leaf commands
|
|
toolNames := sortedToolNames(cli.ToolOverrides)
|
|
for _, toolName := range toolNames {
|
|
override := cli.ToolOverrides[toolName]
|
|
// §1.5: toolOverrides[tool].hidden = true → skip
|
|
if override.Hidden {
|
|
continue
|
|
}
|
|
|
|
cliName := strings.TrimSpace(override.CLIName)
|
|
if cliName == "" {
|
|
cliName = deriveCommandName(toolName, cli.Prefixes)
|
|
}
|
|
|
|
bindings, normalizer := buildOverrideBindings(override)
|
|
|
|
// Resolve Short/Long from Detail API toolTitle/toolDesc; fallback to generic.
|
|
short := fmt.Sprintf("%s/%s", cmdName, cliName)
|
|
long := ""
|
|
if dt, ok := detailIndex[toolName]; ok {
|
|
if title := strings.TrimSpace(dt.ToolTitle); title != "" {
|
|
short = title
|
|
}
|
|
if desc := strings.TrimSpace(dt.ToolDesc); desc != "" {
|
|
long = desc
|
|
}
|
|
}
|
|
|
|
route := Route{
|
|
Use: cliName,
|
|
Short: short,
|
|
Long: long,
|
|
Target: Target{
|
|
CanonicalProduct: strings.TrimSpace(cli.ID),
|
|
Tool: toolName,
|
|
},
|
|
Bindings: bindings,
|
|
Normalizer: normalizer,
|
|
}
|
|
|
|
// §5.1: isSensitive → need --yes confirmation
|
|
if override.IsSensitive {
|
|
route.Normalizer = chainSensitiveNormalizer(normalizer)
|
|
}
|
|
|
|
cmd := NewDirectCommand(route, runner)
|
|
|
|
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
|
|
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
|
|
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
|
|
}
|
|
|
|
// §1.4: Add to the right parent group
|
|
groupName := strings.TrimSpace(override.Group)
|
|
if groupName != "" {
|
|
parent := resolveNestedGroup(rootCmd, groupName, groupCmds)
|
|
parent.AddCommand(cmd)
|
|
} else {
|
|
rootCmd.AddCommand(cmd)
|
|
}
|
|
}
|
|
|
|
built = append(built, builtCmd{cmd: rootCmd, parent: strings.TrimSpace(cli.Parent)})
|
|
}
|
|
|
|
// Collect top-level commands first, then attach child commands via cli.Parent.
|
|
topLevel := make(map[string]*cobra.Command)
|
|
var topOrder []string
|
|
var children []builtCmd
|
|
|
|
for _, b := range built {
|
|
if b.parent == "" {
|
|
name := b.cmd.Name()
|
|
if _, exists := topLevel[name]; !exists {
|
|
topOrder = append(topOrder, name)
|
|
}
|
|
topLevel[name] = b.cmd
|
|
} else {
|
|
children = append(children, b)
|
|
}
|
|
}
|
|
for _, child := range children {
|
|
if parent, ok := topLevel[child.parent]; ok {
|
|
parent.AddCommand(child.cmd)
|
|
} else {
|
|
// Parent not found among dynamic commands; emit as top-level.
|
|
name := child.cmd.Name()
|
|
if _, exists := topLevel[name]; !exists {
|
|
topOrder = append(topOrder, name)
|
|
}
|
|
topLevel[name] = child.cmd
|
|
}
|
|
}
|
|
|
|
commands := make([]*cobra.Command, 0, len(topLevel))
|
|
for _, name := range topOrder {
|
|
commands = append(commands, topLevel[name])
|
|
}
|
|
return commands
|
|
}
|
|
|
|
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
|
|
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
|
|
idx := make(map[string]market.DetailTool, len(tools))
|
|
for _, dt := range tools {
|
|
name := strings.TrimSpace(dt.ToolName)
|
|
if name != "" {
|
|
idx[name] = dt
|
|
}
|
|
}
|
|
return idx
|
|
}
|
|
|
|
// toolRequestSchema is the minimal JSON Schema structure we need from toolRequest.
|
|
type toolRequestSchema struct {
|
|
Properties map[string]toolRequestProp `json:"properties"`
|
|
Required []string `json:"required"`
|
|
}
|
|
|
|
type toolRequestProp struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Default string `json:"default,omitempty"`
|
|
}
|
|
|
|
// buildFlagsFromDetailSchema adds properly-typed cobra flags to cmd based on
|
|
// the MCP toolRequest JSON Schema. The flagOverrides map (from CLIToolOverride.Flags)
|
|
// provides the display-layer alias so the flag name matches what users see.
|
|
//
|
|
// Already-registered flags (from buildOverrideBindings) are skipped to avoid duplicates.
|
|
func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverrides map[string]market.CLIFlagOverride) {
|
|
var schema toolRequestSchema
|
|
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
|
|
return
|
|
}
|
|
if len(schema.Properties) == 0 {
|
|
return
|
|
}
|
|
|
|
requiredSet := make(map[string]bool, len(schema.Required))
|
|
for _, r := range schema.Required {
|
|
requiredSet[r] = true
|
|
}
|
|
|
|
// Process properties in sorted order for determinism.
|
|
keys := make([]string, 0, len(schema.Properties))
|
|
for k := range schema.Properties {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
prop := schema.Properties[key]
|
|
|
|
// Determine flag name: prefer alias from CLIFlagOverride, else kebab-case.
|
|
flagName := toKebabCase(key)
|
|
if ov, ok := flagOverrides[key]; ok && strings.TrimSpace(ov.Alias) != "" {
|
|
flagName = strings.TrimSpace(ov.Alias)
|
|
}
|
|
|
|
// Skip reserved names and already-registered flags.
|
|
if flagName == "json" || flagName == "params" {
|
|
continue
|
|
}
|
|
if cmd.Flags().Lookup(flagName) != nil {
|
|
// Already registered by buildOverrideBindings; just update usage text if empty.
|
|
if f := cmd.Flags().Lookup(flagName); f != nil && f.Usage == key {
|
|
help := strings.TrimSpace(prop.Description)
|
|
if help == "" {
|
|
help = strings.TrimSpace(prop.Title)
|
|
}
|
|
if help != "" {
|
|
f.Usage = help
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
help := strings.TrimSpace(prop.Description)
|
|
if help == "" {
|
|
help = strings.TrimSpace(prop.Title)
|
|
}
|
|
if help == "" {
|
|
help = key
|
|
}
|
|
|
|
switch prop.Type {
|
|
case "integer", "number":
|
|
cmd.Flags().Int(flagName, 0, help)
|
|
case "boolean":
|
|
cmd.Flags().Bool(flagName, false, help)
|
|
case "array":
|
|
cmd.Flags().StringSlice(flagName, nil, help+" (comma-separated)")
|
|
default: // "string", "object", or unknown → string
|
|
defaultVal := prop.Default
|
|
cmd.Flags().String(flagName, defaultVal, help)
|
|
}
|
|
|
|
if requiredSet[key] {
|
|
_ = cmd.MarkFlagRequired(flagName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// toKebabCase converts camelCase or snake_case identifiers to kebab-case.
|
|
// Examples: "parentDentryUuid" → "parent-dentry-uuid", "report_id" → "report-id"
|
|
func toKebabCase(s string) string {
|
|
var b strings.Builder
|
|
for i, r := range s {
|
|
if r == '_' {
|
|
b.WriteByte('-')
|
|
continue
|
|
}
|
|
if unicode.IsUpper(r) && i > 0 {
|
|
b.WriteByte('-')
|
|
}
|
|
b.WriteRune(unicode.ToLower(r))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ServerEndpoints extracts product ID → endpoint URL mapping from servers.
|
|
func ServerEndpoints(servers []market.ServerDescriptor) map[string]string {
|
|
endpoints := make(map[string]string)
|
|
for _, server := range servers {
|
|
if server.CLI.Skip {
|
|
continue
|
|
}
|
|
id := strings.TrimSpace(server.CLI.ID)
|
|
endpoint := strings.TrimSpace(server.Endpoint)
|
|
if id != "" && endpoint != "" {
|
|
endpoints[id] = endpoint
|
|
}
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// ServerProductIDs extracts the set of product IDs from servers with CLI metadata.
|
|
func ServerProductIDs(servers []market.ServerDescriptor) map[string]bool {
|
|
ids := make(map[string]bool)
|
|
for _, server := range servers {
|
|
if server.CLI.Skip {
|
|
continue
|
|
}
|
|
id := strings.TrimSpace(server.CLI.ID)
|
|
if id != "" {
|
|
ids[id] = true
|
|
}
|
|
cmd := strings.TrimSpace(server.CLI.Command)
|
|
if cmd != "" && cmd != id {
|
|
ids[cmd] = true
|
|
}
|
|
for _, alias := range server.CLI.Aliases {
|
|
alias = strings.TrimSpace(alias)
|
|
if alias != "" {
|
|
ids[alias] = true
|
|
}
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// ensureNestedGroup creates group commands for potentially nested group names.
|
|
// §1.4: "group.members" with "." means parent-child relationship → "dws chat group members"
|
|
func ensureNestedGroup(root *cobra.Command, groupPath, description string, registry map[string]*cobra.Command) *cobra.Command {
|
|
if existing, ok := registry[groupPath]; ok {
|
|
return existing
|
|
}
|
|
|
|
parts := strings.Split(groupPath, ".")
|
|
parent := root
|
|
builtPath := ""
|
|
for i, part := range parts {
|
|
if builtPath == "" {
|
|
builtPath = part
|
|
} else {
|
|
builtPath = builtPath + "." + part
|
|
}
|
|
|
|
if existing, ok := registry[builtPath]; ok {
|
|
parent = existing
|
|
continue
|
|
}
|
|
|
|
desc := part
|
|
if i == len(parts)-1 {
|
|
desc = description
|
|
}
|
|
gc := NewGroupCommand(part, desc)
|
|
parent.AddCommand(gc)
|
|
registry[builtPath] = gc
|
|
parent = gc
|
|
}
|
|
return parent
|
|
}
|
|
|
|
// resolveNestedGroup finds or creates the group command for a potentially nested group path.
|
|
func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[string]*cobra.Command) *cobra.Command {
|
|
if existing, ok := registry[groupPath]; ok {
|
|
return existing
|
|
}
|
|
// Auto-create if not defined in groups
|
|
return ensureNestedGroup(root, groupPath, groupPath, registry)
|
|
}
|
|
|
|
// buildOverrideBindings converts CLIToolOverride flags into FlagBindings and
|
|
// constructs a Normalizer that applies transform rules.
|
|
// Implements §2.1-§2.5 of the conversion rules.
|
|
func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Normalizer) {
|
|
if len(override.Flags) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
paramNames := make([]string, 0, len(override.Flags))
|
|
for paramName := range override.Flags {
|
|
paramNames = append(paramNames, paramName)
|
|
}
|
|
sort.Strings(paramNames)
|
|
|
|
var bindings []FlagBinding
|
|
type transformEntry struct {
|
|
paramName string
|
|
transform string
|
|
transformArgs map[string]any
|
|
}
|
|
var transforms []transformEntry
|
|
type envDefaultEntry struct {
|
|
paramName string
|
|
envVar string
|
|
}
|
|
var envDefaults []envDefaultEntry
|
|
type hiddenDefaultEntry struct {
|
|
paramName string
|
|
defaultValue string
|
|
}
|
|
var hiddenDefaults []hiddenDefaultEntry
|
|
|
|
for _, paramName := range paramNames {
|
|
flagOverride := override.Flags[paramName]
|
|
|
|
// §2.2: flag name from alias, fallback to kebab-case of param name
|
|
flagName := strings.TrimSpace(flagOverride.Alias)
|
|
if flagName == "" {
|
|
flagName = compatFlagName(paramName)
|
|
}
|
|
if flagName == "" {
|
|
flagName = paramName
|
|
}
|
|
|
|
// Skip reserved internal flag names (--json, --params are added by ApplyBindings)
|
|
if flagName == "json" || flagName == "params" {
|
|
continue
|
|
}
|
|
|
|
binding := FlagBinding{
|
|
FlagName: flagName,
|
|
Property: paramName,
|
|
Kind: ValueString,
|
|
Usage: paramName,
|
|
}
|
|
|
|
// §2.5: hidden flag with default
|
|
if flagOverride.Hidden {
|
|
// Hidden flags are still added but marked hidden.
|
|
// They are auto-populated with their default value via the normalizer.
|
|
if flagOverride.Default != "" {
|
|
hiddenDefaults = append(hiddenDefaults, hiddenDefaultEntry{
|
|
paramName: paramName,
|
|
defaultValue: flagOverride.Default,
|
|
})
|
|
}
|
|
}
|
|
|
|
bindings = append(bindings, binding)
|
|
|
|
if flagOverride.Transform != "" {
|
|
transforms = append(transforms, transformEntry{
|
|
paramName: paramName,
|
|
transform: flagOverride.Transform,
|
|
transformArgs: flagOverride.TransformArgs,
|
|
})
|
|
}
|
|
if flagOverride.EnvDefault != "" {
|
|
envDefaults = append(envDefaults, envDefaultEntry{
|
|
paramName: paramName,
|
|
envVar: flagOverride.EnvDefault,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check if we need a normalizer: transforms, env defaults, hidden defaults,
|
|
// or dotted property paths that need nesting.
|
|
needsDottedNesting := false
|
|
for _, b := range bindings {
|
|
if strings.Contains(b.Property, ".") {
|
|
needsDottedNesting = true
|
|
break
|
|
}
|
|
}
|
|
if len(transforms) == 0 && len(envDefaults) == 0 && len(hiddenDefaults) == 0 && !needsDottedNesting {
|
|
return bindings, nil
|
|
}
|
|
|
|
// Build a normalizer that applies hidden defaults + env defaults + transforms + nesting
|
|
normalizer := func(cmd *cobra.Command, params map[string]any) error {
|
|
// §2.5: Apply hidden flag defaults for parameters not explicitly set
|
|
for _, hd := range hiddenDefaults {
|
|
if _, exists := params[hd.paramName]; !exists {
|
|
params[hd.paramName] = hd.defaultValue
|
|
}
|
|
}
|
|
|
|
// Apply environment variable defaults for parameters not explicitly set
|
|
for _, ed := range envDefaults {
|
|
if _, exists := params[ed.paramName]; !exists {
|
|
if envVal := strings.TrimSpace(os.Getenv(ed.envVar)); envVal != "" {
|
|
params[ed.paramName] = envVal
|
|
}
|
|
}
|
|
}
|
|
|
|
// §3: Apply transforms
|
|
for _, t := range transforms {
|
|
val, exists := params[t.paramName]
|
|
if !exists {
|
|
// For enum_map with _default, apply default even when flag is omitted
|
|
if t.transform == "enum_map" && t.transformArgs != nil {
|
|
if defaultVal, hasDefault := t.transformArgs["_default"]; hasDefault {
|
|
params[t.paramName] = defaultVal
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
transformed, err := ApplyTransform(val, t.transform, t.transformArgs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params[t.paramName] = transformed
|
|
}
|
|
|
|
// Nest dotted property paths: "Body.query" → params["Body"]["query"]
|
|
nestDottedPaths(params)
|
|
|
|
return nil
|
|
}
|
|
|
|
return bindings, normalizer
|
|
}
|
|
|
|
// chainSensitiveNormalizer wraps a normalizer with --yes confirmation for sensitive operations (§5.1).
|
|
func chainSensitiveNormalizer(inner Normalizer) Normalizer {
|
|
return func(cmd *cobra.Command, params map[string]any) error {
|
|
if inner != nil {
|
|
if err := inner(cmd, params); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return requireYesForDelete(cmd, params)
|
|
}
|
|
}
|
|
|
|
// deriveCommandName converts an MCP tool name to a CLI command name
|
|
// by stripping known prefixes and converting to kebab-case.
|
|
func deriveCommandName(toolName string, prefixes []string) string {
|
|
name := toolName
|
|
for _, prefix := range prefixes {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
stripped := strings.TrimPrefix(name, prefix+"_")
|
|
if stripped != name {
|
|
name = stripped
|
|
break
|
|
}
|
|
}
|
|
return compatFlagName(name)
|
|
}
|
|
|
|
func sortedKeys(m map[string]market.CLIGroupDef) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func sortedToolNames(m map[string]market.CLIToolOverride) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// nestDottedPaths converts flat dotted keys in params into nested maps.
|
|
// Example: params["Body.query"] = "test" → params["Body"] = map{"query": "test"}
|
|
// If multiple dotted keys share a prefix, they are merged into the same nested map.
|
|
func nestDottedPaths(params map[string]any) {
|
|
var dottedKeys []string
|
|
for key := range params {
|
|
if strings.Contains(key, ".") {
|
|
dottedKeys = append(dottedKeys, key)
|
|
}
|
|
}
|
|
if len(dottedKeys) == 0 {
|
|
return
|
|
}
|
|
sort.Strings(dottedKeys)
|
|
for _, key := range dottedKeys {
|
|
val := params[key]
|
|
delete(params, key)
|
|
|
|
parts := strings.SplitN(key, ".", 2)
|
|
if len(parts) != 2 {
|
|
params[key] = val // shouldn't happen, but be safe
|
|
continue
|
|
}
|
|
parent, child := parts[0], parts[1]
|
|
|
|
// Get or create the nested map
|
|
nested, ok := params[parent].(map[string]any)
|
|
if !ok {
|
|
nested = make(map[string]any)
|
|
params[parent] = nested
|
|
}
|
|
nested[child] = val
|
|
}
|
|
}
|