714 lines
20 KiB
Go
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(), "-")
|
|
}
|