- Add StdinGuard for safe single-read stdin across multiple flags - Add ResolveInputSource unified resolver for @file, @-, and pipe - Support @file / @- syntax for all string-typed override flags - Remove unused writeSchemaText function
806 lines
23 KiB
Go
806 lines
23 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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||
"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, engine *pipeline.Engine) *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, engine)
|
||
cmd.AddCommand(productCommand)
|
||
addGroupedProductAlias(cmd, product, runner, engine)
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "schema [product.tool]",
|
||
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
|
||
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
|
||
|
||
不带参数时列出所有产品及其工具数量;带 product.tool 路径时
|
||
输出该工具的完整输入 Schema(JSON Schema 格式)。
|
||
|
||
示例:
|
||
dws schema # 列出所有产品
|
||
dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema
|
||
dws schema --fields id,tools # 只显示 id 和 tools 字段
|
||
dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`,
|
||
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
|
||
}
|
||
|
||
payload, err := schemaPayload(catalog, args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return output.WriteFiltered(
|
||
cmd.OutOrStdout(),
|
||
output.ResolveFormat(cmd, output.FormatJSON),
|
||
payload,
|
||
output.ResolveFields(cmd),
|
||
output.ResolveJQ(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, engine *pipeline.Engine) *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, engine))
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) {
|
||
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, engine)
|
||
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, engine *pipeline.Engine) *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
|
||
}
|
||
|
||
// One guard per invocation ensures stdin is read at most once.
|
||
guard := NewStdinGuard()
|
||
|
||
jsonPayload, err := cmd.Flags().GetString("json")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --json")
|
||
}
|
||
|
||
// Resolve @file / @- for --json flag.
|
||
jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
paramsPayload, err := cmd.Flags().GetString("params")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --params")
|
||
}
|
||
|
||
// Resolve @file / @- for all string-typed override flags BEFORE
|
||
// the implicit stdin fallback, so explicit @- in any flag takes
|
||
// priority over the implicit pipe read.
|
||
overrides, err := collectOverrides(cmd, specs, guard)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Implicit stdin fallback (lowest priority): if no --json was
|
||
// given and no flag claimed stdin via @-, read from pipe.
|
||
if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() {
|
||
if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil {
|
||
return claimErr
|
||
}
|
||
stdinData, stdinErr := ReadStdin()
|
||
if stdinErr != nil {
|
||
return stdinErr
|
||
}
|
||
jsonPayload = stdinData
|
||
}
|
||
|
||
params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// PostParse: normalise parameter values (date formats,
|
||
// booleans, enums) using the tool's input schema.
|
||
if engine != nil && engine.HasHandlers(pipeline.PostParse) {
|
||
pctx := &pipeline.Context{
|
||
Command: tool.CanonicalPath,
|
||
Params: params,
|
||
Schema: tool.InputSchema,
|
||
}
|
||
if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil {
|
||
return pipeErr
|
||
}
|
||
params = pctx.Params
|
||
}
|
||
|
||
if err := ValidateInputSchema(params, tool.InputSchema); err != nil {
|
||
return err
|
||
}
|
||
if !dryRun {
|
||
if err := confirmSensitiveTool(cmd, tool, guard); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// PreRequest: last chance to inspect/mutate payload before
|
||
// the JSON-RPC call is dispatched.
|
||
if engine != nil && engine.HasHandlers(pipeline.PreRequest) {
|
||
pctx := &pipeline.Context{
|
||
Command: tool.CanonicalPath,
|
||
Params: params,
|
||
Schema: tool.InputSchema,
|
||
Payload: params,
|
||
}
|
||
if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil {
|
||
return pipeErr
|
||
}
|
||
params = pctx.Params
|
||
}
|
||
|
||
invocation := executor.NewInvocation(product, tool, params)
|
||
invocation.DryRun = dryRun
|
||
result, err := runner.Run(cmd.Context(), invocation)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// PostResponse: transform or enrich the response before
|
||
// writing it to stdout.
|
||
if engine != nil && engine.HasHandlers(pipeline.PostResponse) {
|
||
pctx := &pipeline.Context{
|
||
Command: tool.CanonicalPath,
|
||
Params: params,
|
||
Schema: tool.InputSchema,
|
||
Response: result.Response,
|
||
}
|
||
if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil {
|
||
return pipeErr
|
||
}
|
||
result.Response = pctx.Response
|
||
}
|
||
|
||
if warning := lifecycleWarning(product); warning != "" {
|
||
if result.Response == nil {
|
||
result.Response = map[string]any{}
|
||
}
|
||
result.Response["warning"] = warning
|
||
}
|
||
return output.WriteFiltered(
|
||
cmd.OutOrStdout(),
|
||
output.ResolveFormat(cmd, output.FormatJSON),
|
||
result,
|
||
output.ResolveFields(cmd),
|
||
output.ResolveJQ(cmd),
|
||
)
|
||
},
|
||
}
|
||
|
||
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, guard *StdinGuard) (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))
|
||
}
|
||
// Resolve @file / @- for all string-typed flags.
|
||
resolved, resolveErr := ResolveInputSource(value, flagName, guard)
|
||
if resolveErr != nil {
|
||
return nil, resolveErr
|
||
}
|
||
overrides[spec.PropertyName] = resolved
|
||
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 confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) 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
|
||
}
|
||
|
||
// Stdin was consumed for data input — interactive confirmation is impossible.
|
||
if guard != nil && guard.Claimed() {
|
||
return apperrors.NewValidation(
|
||
"stdin used for data input; pass --yes to confirm sensitive operation",
|
||
)
|
||
}
|
||
|
||
_, _ = 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(), "-")
|
||
}
|