Files
dingtalk-workspace-cli/internal/cli/canonical.go
T
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

1015 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
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/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"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 {
cmd := &cobra.Command{
Use: "schema [path]",
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP
注解和 CLI 层的 flag overlay(alias/transform/env_default)。
路径支持三种写法:
product.rpc_name 规范路径 (e.g. ding.send_ding_message)
product.group.cli_name CLI 点路径 (e.g. ding.message.send)
"product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send")
示例:
dws schema # 列出所有产品
dws schema ding.send_ding_message # 规范路径
dws schema "ding message send" # CLI 路径(空格)
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
dws schema calendar.create_event --jq '.tool.auth'
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
cliPath, _ := cmd.Flags().GetString("cli-path")
cliPath = strings.TrimSpace(cliPath)
if cliPath != "" {
if len(args) > 0 {
return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive")
}
args = []string{cliPath}
}
catalog, err := loader.Load(cmd.Context())
if err != nil {
var degraded *CatalogDegraded
if errors.As(err, &degraded) {
fmt.Fprintf(cmd.ErrOrStderr(), "hint: %s\n", degraded.Hint)
payload := map[string]any{
"kind": "schema",
"count": 0,
"products": []any{},
"degraded": true,
"reason": string(degraded.Reason),
"hint": degraded.Hint,
}
return output.WriteFiltered(
cmd.OutOrStdout(),
output.ResolveFormat(cmd, output.FormatJSON),
payload,
output.ResolveFields(cmd),
output.ResolveJQ(cmd),
)
}
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),
)
},
}
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
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, 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, 2)
seenAlias := map[string]bool{product.ID: true}
addAlias := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seenAlias[s] {
return
}
seenAlias[s] = true
aliases = append(aliases, s)
}
if preferred := preferredProductRouteToken(product); preferred != "" {
addAlias(preferred)
}
// Consume only cli.Aliases (canonical alternate-name field).
// cli.Prefixes is the tool-name-prefix pool consumed by deriveCommandName;
// treating prefixes[1:] as aliases over-registers names the wukong edition
// does not expose, breaking cross-edition parity.
if product.CLI != nil {
for _, a := range product.CLI.Aliases {
addAlias(a)
}
}
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))
}
// Register phase: notify the pipeline that a product and its
// tools have been added to the command tree. This runs once at
// startup (not per-request) and enables handlers to inspect or
// enrich the registered command surface.
if engine != nil && engine.HasHandlers(pipeline.Register) {
pctx := &pipeline.Context{
Command: product.ID,
}
// Best-effort — registration errors are logged but do not
// prevent the CLI from starting.
if pipeErr := engine.RunPhase(pipeline.Register, pctx); pipeErr != nil {
slog.Debug("pipeline register phase", "product", product.ID, "error", pipeErr)
} else {
slog.Debug("pipeline register",
"product", product.ID,
"tool_count", len(product.Tools),
)
}
}
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
for _, c := range pctx.Corrections {
slog.Debug("pipeline correction",
"phase", "post-parse",
"handler", c.Handler,
"kind", c.Kind,
"field", c.Field,
"original", c.Original,
"corrected", c.Corrected,
)
}
}
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
slog.Debug("pipeline pre-request",
"command", tool.CanonicalPath,
"param_count", len(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
slog.Debug("pipeline post-response",
"command", tool.CanonicalPath,
"has_response", result.Response != nil,
)
}
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 {
products := make([]map[string]any, 0, len(catalog.Products))
for _, p := range catalog.Products {
tools := make([]map[string]any, 0, len(p.Tools))
for _, t := range p.Tools {
tools = append(tools, compactTool(t))
}
products = append(products, map[string]any{
"id": p.ID,
"name": p.DisplayName,
"description": p.Description,
"tools": tools,
})
}
return map[string]any{
"kind": "schema",
"count": len(products),
"products": products,
}, nil
}
product, tool, ok := resolveSchemaPath(catalog, 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": map[string]any{"id": product.ID, "name": product.DisplayName},
"tool": compactTool(tool),
}, nil
}
// resolveSchemaPath accepts three input forms and maps to (product, tool):
// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message")
// - "product.cli_name" (single-level CLI path, e.g. "doc.create")
// - CLI path with group ("ding message send" or "ding.message.send";
// also accepts "/" and multiple whitespace between tokens)
//
// Canonical form is tried first so existing callers and scripts keep
// working; only when that fails does the CLI-path resolver run.
func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
if product, tool, ok := catalog.FindTool(raw); ok {
return product, tool, true
}
tokens := splitSchemaPathTokens(raw)
if len(tokens) < 2 {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
productID := tokens[0]
leaf := tokens[len(tokens)-1]
groupPath := strings.Join(tokens[1:len(tokens)-1], ".")
product, ok := catalog.FindProduct(productID)
if !ok {
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
for _, tool := range product.Tools {
if tool.CLIName != leaf {
continue
}
if strings.TrimSpace(tool.Group) != groupPath {
continue
}
return product, tool, true
}
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
}
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
// whitespace, returning only non-empty tokens. "ding message send",
// "ding.message.send", and "ding/message/send" all yield the same
// three tokens.
func splitSchemaPathTokens(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '.' || r == '/' || r == ' ' || r == '\t'
})
out := fields[:0]
for _, f := range fields {
if s := strings.TrimSpace(f); s != "" {
out = append(out, s)
}
}
return out
}
// compactTool returns a lean representation of a tool for schema
// output, keeping the fields AI agents and scripts need: RPC + CLI
// identity, input/output schema, sensitivity, MCP annotations, and the
// CLI flag overlay (alias/transform/envDefault/default) that shapes
// how raw MCP parameters appear on the command line.
func compactTool(t ir.ToolDescriptor) map[string]any {
tool := map[string]any{
"name": t.RPCName,
"cli_name": t.CLIName,
"canonical_path": t.CanonicalPath,
"title": t.Title,
"description": t.Description,
"sensitive": t.Sensitive,
}
if strings.TrimSpace(t.Group) != "" {
tool["group"] = t.Group
}
if props, ok := t.InputSchema["properties"]; ok {
tool["parameters"] = props
}
if req := requiredFields(t.InputSchema); len(req) > 0 {
tool["required"] = req
}
if len(t.OutputSchema) > 0 {
tool["output_schema"] = t.OutputSchema
}
if t.Annotations != nil {
tool["annotations"] = t.Annotations
}
if t.Auth != nil {
tool["auth"] = t.Auth
}
if len(t.FlagOverlay) > 0 {
tool["flag_overlay"] = t.FlagOverlay
}
return tool
}
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(), "-")
}