Files
dingtalk-workspace-cli/internal/cli/canonical.go
T
2026-03-27 17:55:57 +08:00

714 lines
20 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 cli
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
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/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
type FlagKind string
const (
flagString FlagKind = "string"
flagInteger FlagKind = "integer"
flagNumber FlagKind = "number"
flagBoolean FlagKind = "boolean"
flagStringArray FlagKind = "string_array"
flagIntegerList FlagKind = "integer_array"
flagNumberList FlagKind = "number_array"
flagBooleanList FlagKind = "boolean_array"
flagJSON FlagKind = "json"
)
type FlagSpec struct {
PropertyName string
FlagName string
Alias string
Shorthand string
Kind FlagKind
Description string
}
func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner) *cobra.Command {
catalog, loadErr := loader.Load(ctx)
longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp."
if loadErr != nil {
longDescription += fmt.Sprintf("\n\nDiscovery note: %v", loadErr)
}
if len(catalog.Products) == 0 {
longDescription += "\n\nNo canonical products are currently loaded. Set DWS_CATALOG_FIXTURE to populate the surface."
}
cmd := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface",
Long: longDescription,
Hidden: false,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
if loadErr != nil {
cmd.Args = cobra.ArbitraryArgs
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
return loadErr
}
return cmd
}
for _, product := range catalog.Products {
if product.CLI != nil && product.CLI.Skip {
continue
}
productCommand := newProductCommand(product, runner)
cmd.AddCommand(productCommand)
addGroupedProductAlias(cmd, product, runner)
}
return cmd
}
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
cmd := &cobra.Command{
Use: "schema [canonical-product.tool]",
Short: "Inspect canonical schema metadata",
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
jsonOut, err := cmd.Flags().GetBool("json")
if err != nil {
return apperrors.NewInternal("failed to read schema flags")
}
payload, err := schemaPayload(catalog, args)
if err != nil {
return err
}
if jsonOut {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
return writeSchemaText(cmd.OutOrStdout(), payload)
},
}
cmd.Flags().Bool("json", false, "Emit schema metadata as JSON")
return cmd
}
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
properties, ok := nestedMap(schema, "properties")
if !ok {
return nil
}
keys := make([]string, 0, len(properties))
for key := range properties {
keys = append(keys, key)
}
sort.Strings(keys)
specs := make([]FlagSpec, 0, len(keys))
for _, key := range keys {
propertySchema, ok := properties[key].(map[string]any)
if !ok {
continue
}
kind, ok := flagKindForSchema(propertySchema)
if !ok {
continue
}
specs = append(specs, FlagSpec{
PropertyName: key,
FlagName: strings.ReplaceAll(key, "_", "-"),
Alias: strings.TrimSpace(hints[key].Alias),
Shorthand: strings.TrimSpace(hints[key].Shorthand),
Kind: kind,
Description: schemaDescription(propertySchema),
})
}
return specs
}
func newProductCommand(product ir.CanonicalProduct, runner executor.Runner) *cobra.Command {
shortDescription := product.DisplayName
if strings.TrimSpace(product.Description) != "" {
shortDescription = product.Description
}
if shortDescription == "" {
shortDescription = product.ID
}
aliases := make([]string, 0, 1)
if preferred := preferredProductRouteToken(product); preferred != "" && preferred != product.ID {
aliases = append(aliases, preferred)
}
cmd := &cobra.Command{
Use: product.ID,
Aliases: aliases,
Short: shortDescription,
Hidden: product.CLI != nil && product.CLI.Hidden,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
if product.CLI != nil && strings.TrimSpace(product.CLI.Group) != "" {
cmd.Long = fmt.Sprintf("%s\n\nGroup: %s", shortDescription, product.CLI.Group)
}
if warning := lifecycleWarning(product); warning != "" {
if strings.TrimSpace(cmd.Long) == "" {
cmd.Long = shortDescription
}
cmd.Long = strings.TrimSpace(cmd.Long + "\n\nLifecycle: " + warning)
}
for _, tool := range product.Tools {
cmd.AddCommand(newToolCommand(product, tool, runner))
}
return cmd
}
func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner) {
if root == nil || product.CLI == nil {
return
}
groupPath := splitRouteTokens(product.CLI.Group)
if len(groupPath) == 0 {
return
}
commandPath := splitRouteTokens(product.CLI.Command)
if len(commandPath) == 0 {
commandPath = []string{product.ID}
}
fullPath := append(append([]string{}, groupPath...), commandPath...)
if len(fullPath) == 0 {
return
}
parent := root
for _, token := range fullPath[:len(fullPath)-1] {
existing := cobracmd.ChildByName(parent, token)
if existing != nil {
parent = existing
continue
}
groupCommand := &cobra.Command{
Use: token,
Short: fmt.Sprintf("Canonical group %s", token),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
parent.AddCommand(groupCommand)
parent = groupCommand
}
leaf := fullPath[len(fullPath)-1]
if cobracmd.ChildByName(parent, leaf) != nil {
return
}
aliasProduct := product
if aliasProduct.CLI != nil {
cliCopy := *aliasProduct.CLI
cliCopy.Command = ""
cliCopy.Group = ""
aliasProduct.CLI = &cliCopy
}
productCommand := newProductCommand(aliasProduct, runner)
productCommand.Use = leaf
productCommand.Aliases = nil
if leaf != aliasProduct.ID {
productCommand.Aliases = append(productCommand.Aliases, aliasProduct.ID)
}
parent.AddCommand(productCommand)
}
func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner) *cobra.Command {
shortDescription := tool.Title
if strings.TrimSpace(tool.Description) != "" {
shortDescription = tool.Description
}
specs := BuildFlagSpecs(tool.InputSchema, tool.FlagHints)
use := strings.TrimSpace(tool.CLIName)
if use == "" {
use = tool.RPCName
}
aliases := make([]string, 0, 1)
if use != tool.RPCName {
aliases = append(aliases, tool.RPCName)
}
cmd := &cobra.Command{
Use: use,
Aliases: aliases,
Short: shortDescription,
Hidden: tool.Hidden,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if warning := lifecycleWarning(product); warning != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warning)
}
dryRun := false
if cmd.Flags().Lookup("dry-run") != nil {
value, err := cmd.Flags().GetBool("dry-run")
if err != nil {
return apperrors.NewInternal("failed to read --dry-run")
}
dryRun = value
}
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")
}
overrides, err := collectOverrides(cmd, specs)
if err != nil {
return err
}
params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides)
if err != nil {
return err
}
if err := ValidateInputSchema(params, tool.InputSchema); err != nil {
return err
}
if !dryRun {
if err := confirmSensitiveTool(cmd, tool); err != nil {
return err
}
}
invocation := executor.NewInvocation(product, tool, params)
invocation.DryRun = dryRun
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
if warning := lifecycleWarning(product); warning != "" {
if result.Response == nil {
result.Response = map[string]any{}
}
result.Response["warning"] = warning
}
return output.WriteJSON(cmd.OutOrStdout(), result)
},
}
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
applyFlagSpecs(cmd, specs)
return cmd
}
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
for _, spec := range specs {
usage := spec.Description
if usage == "" {
usage = fmt.Sprintf("Override %s", spec.PropertyName)
}
primary := strings.TrimSpace(spec.FlagName)
if primary == "" {
continue
}
alias := strings.TrimSpace(spec.Alias)
if alias == primary {
alias = ""
}
switch spec.Kind {
case flagString, flagJSON:
cmd.Flags().StringP(primary, spec.Shorthand, "", usage)
if alias != "" {
cmd.Flags().String(alias, "", usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagInteger:
cmd.Flags().IntP(primary, spec.Shorthand, 0, usage)
if alias != "" {
cmd.Flags().Int(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagNumber:
cmd.Flags().Float64P(primary, spec.Shorthand, 0, usage)
if alias != "" {
cmd.Flags().Float64(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagBoolean:
cmd.Flags().BoolP(primary, spec.Shorthand, false, usage)
if alias != "" {
cmd.Flags().Bool(alias, false, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
cmd.Flags().StringSliceP(primary, spec.Shorthand, nil, usage)
if alias != "" {
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
}
}
}
func collectOverrides(cmd *cobra.Command, specs []FlagSpec) (map[string]any, error) {
overrides := make(map[string]any)
for _, spec := range specs {
flagName := strings.TrimSpace(spec.FlagName)
if alias := strings.TrimSpace(spec.Alias); alias != "" && cobracmd.FlagChanged(cmd, alias) {
flagName = alias
}
flag := cmd.Flags().Lookup(flagName)
if flag == nil || !flag.Changed {
continue
}
switch spec.Kind {
case flagString:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagJSON:
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))
}
overrides[spec.PropertyName] = parsed
case flagInteger:
value, err := cmd.Flags().GetInt(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagNumber:
value, err := cmd.Flags().GetFloat64(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagBoolean:
value, err := cmd.Flags().GetBool(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = value
case flagStringArray:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
overrides[spec.PropertyName] = convert.StringsToAny(value)
case flagIntegerList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
parsed, parseErr := convert.ParseStringList(value, strconv.Atoi)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
overrides[spec.PropertyName] = convert.IntsToAny(parsed)
case flagNumberList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
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))
}
overrides[spec.PropertyName] = convert.FloatsToAny(parsed)
case flagBooleanList:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
overrides[spec.PropertyName] = convert.BoolsToAny(parsed)
}
}
return overrides, nil
}
func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
if len(args) == 0 {
return map[string]any{
"kind": "schema",
"products": catalog.Products,
"count": len(catalog.Products),
}, nil
}
product, tool, ok := catalog.FindTool(args[0])
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
}
return map[string]any{
"kind": "schema",
"path": args[0],
"product": product,
"tool": tool,
"required": requiredFields(tool.InputSchema),
}, nil
}
func writeSchemaText(w io.Writer, payload map[string]any) error {
if path, ok := payload["path"].(string); ok && path != "" {
_, err := fmt.Fprintf(w, "schema for %s\n", path)
return err
}
_, err := fmt.Fprintln(w, "canonical schema catalog")
return err
}
func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor) error {
if !tool.Sensitive {
return nil
}
yes := false
if cmd.Flags().Lookup("yes") != nil {
value, err := cmd.Flags().GetBool("yes")
if err != nil {
return apperrors.NewInternal("failed to read --yes")
}
yes = value
}
if yes {
return nil
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath)
confirmed, err := readYesNo(cmd.InOrStdin())
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read confirmation input: %v", err))
}
if !confirmed {
return apperrors.NewValidation("sensitive operation cancelled; use --yes to skip confirmation")
}
return nil
}
func readYesNo(r io.Reader) (bool, error) {
line, err := bufio.NewReader(r).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
default:
return false, nil
}
}
func lifecycleWarning(product ir.CanonicalProduct) string {
if product.Lifecycle == nil {
return ""
}
if product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" && !product.Lifecycle.DeprecatedCandidate {
return ""
}
parts := make([]string, 0, 3)
if product.Lifecycle.DeprecatedCandidate && product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" {
parts = append(parts, fmt.Sprintf("product %s is marked as legacy candidate", product.ID))
} else {
parts = append(parts, fmt.Sprintf("product %s is deprecated", product.ID))
}
if product.Lifecycle.DeprecatedBy > 0 {
parts = append(parts, fmt.Sprintf("deprecated_by_mcpId=%d", product.Lifecycle.DeprecatedBy))
}
if strings.TrimSpace(product.Lifecycle.DeprecationDate) != "" {
parts = append(parts, "deprecation_date="+strings.TrimSpace(product.Lifecycle.DeprecationDate))
}
if strings.TrimSpace(product.Lifecycle.MigrationURL) != "" {
parts = append(parts, "migration="+strings.TrimSpace(product.Lifecycle.MigrationURL))
}
return strings.Join(parts, "; ")
}
func nestedMap(root map[string]any, key string) (map[string]any, bool) {
if root == nil {
return nil, false
}
value, ok := root[key]
if !ok {
return nil, false
}
out, ok := value.(map[string]any)
return out, ok
}
func flagKindForSchema(schema map[string]any) (FlagKind, bool) {
if _, ok := schema["enum"].([]any); ok {
return flagString, true
}
switch schema["type"] {
case "string":
return flagString, true
case "integer":
return flagInteger, true
case "number":
return flagNumber, true
case "boolean":
return flagBoolean, true
case "object":
return flagJSON, true
case "array":
items, ok := schema["items"].(map[string]any)
if !ok {
return flagJSON, true
}
if _, ok := items["enum"].([]any); ok {
return flagStringArray, true
}
switch items["type"] {
case "string":
return flagStringArray, true
case "integer":
return flagIntegerList, true
case "number":
return flagNumberList, true
case "boolean":
return flagBooleanList, true
case "object":
return flagJSON, true
}
}
return "", false
}
func schemaDescription(schema map[string]any) string {
value, _ := schema["description"].(string)
return strings.TrimSpace(value)
}
func requiredFields(schema map[string]any) []string {
raw, ok := schema["required"].([]any)
if !ok {
return nil
}
fields := make([]string, 0, len(raw))
for _, entry := range raw {
value, ok := entry.(string)
if ok && value != "" {
fields = append(fields, value)
}
}
return fields
}
func preferredProductRouteToken(product ir.CanonicalProduct) string {
if product.CLI == nil {
return ""
}
parts := splitRouteTokens(product.CLI.Command)
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func splitRouteTokens(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
segments := strings.FieldsFunc(raw, func(r rune) bool {
return r == '/' || r == '\\' || r == '.'
})
out := make([]string, 0, len(segments))
for _, segment := range segments {
normalized := normalizeRouteToken(segment)
if normalized == "" {
continue
}
out = append(out, normalized)
}
return out
}
func normalizeRouteToken(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return ""
}
var builder strings.Builder
lastDash := false
for _, r := range raw {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastDash = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == ' ':
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}