Sync with DingTalk-Real-AI/main (1.0.35–1.0.40): - 1.0.35–1.0.39: chat @-mentions, sheet export, doc strip unsafe chars, PAT batch grants, devdoc RAG, agent_code detection, AI-tag opt-in - 1.0.40 (#486): China mirror via Gitee + npmmirror - (#488) embed skills/ into binary - (#464) strip duplicate H1 on JSONML doc-create # Conflicts: # .github/workflows/release.yml # CHANGELOG.md # README.md # README_zh.md # docs/command-index.md # internal/app/runner.go # internal/app/runner_test.go # internal/auth/channel.go # internal/helpers/devdoc.go # internal/helpers/devdoc_test.go # internal/pat/chmod.go # internal/pat/chmod_test.go # internal/pat/pat.go # internal/transport/client.go # scripts/install-skills.sh # scripts/install.ps1 # scripts/install.sh # skills/mono/references/products/devdoc.md # skills/mono/references/products/simple.md # skills/multi/dingtalk-devdoc/references/devdoc.md # skills/multi/dingtalk-skill/references/skill.md # test/cli_compat/devdoc_test.go # test/cli_compat/helpers_test.go # test/skill_tests.md # test/skill_tests_results.md # test/unit/pat_host_owned_signal_test.go
1084 lines
32 KiB
Go
1084 lines
32 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"
|
||
"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, helperTools HelperToolFetcher) *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
|
||
|
||
helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app
|
||
MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required,
|
||
键为 CLI flag):
|
||
dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat)
|
||
dws schema "dev app" # 列出该分组下的子命令`,
|
||
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}
|
||
}
|
||
|
||
// Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery
|
||
// catalog; their schema CONTENT is fetched LIVE from the helper's
|
||
// pinned MCP server (op-app) and rendered in the gws-flat shape, so
|
||
// `dws schema "dev app robot config"` answers without touching
|
||
// discovery. Only the `dev` root claims this path; everything else
|
||
// falls through to the catalog below.
|
||
if len(args) > 0 {
|
||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ok {
|
||
return output.WriteFiltered(
|
||
cmd.OutOrStdout(),
|
||
output.ResolveFormat(cmd, output.FormatJSON),
|
||
payload,
|
||
output.ResolveFields(cmd),
|
||
output.ResolveJQ(cmd),
|
||
)
|
||
}
|
||
}
|
||
|
||
catalog, err := loader.Load(cmd.Context())
|
||
if err != nil {
|
||
var degraded *CatalogDegraded
|
||
if errors.As(err, °raded) {
|
||
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
|
||
}
|
||
|
||
// Append helper-only subtrees (e.g. `dev`) to the no-arg product
|
||
// listing so browsing all products also surfaces helper commands.
|
||
if len(args) == 0 {
|
||
if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 {
|
||
if products, ok := payload["products"].([]map[string]any); ok {
|
||
payload["products"] = append(products, helpers...)
|
||
payload["count"] = len(payload["products"].([]map[string]any))
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// canRegisterToolFlag reports whether a long flag named name can be
|
||
// registered on cmd without panicking pflag ("flag redefined"). The reserved
|
||
// payload names are excluded too: newToolCommand unconditionally registers
|
||
// --json/--params before the spec loop. Tool schemas are remote data — a
|
||
// property named after a reserved or already-registered flag must degrade to
|
||
// "flag unavailable" (the value stays reachable through --json/--params),
|
||
// never abort the process. Mirrors internal/compat's canRegisterFlag.
|
||
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||
if name == "" || name == "json" || name == "params" {
|
||
return false
|
||
}
|
||
return cmd.Flags().Lookup(name) == nil
|
||
}
|
||
|
||
// safeToolShorthand returns short when it is a single-character shorthand not
|
||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||
// pflag panics on both multi-character and duplicate shorthands.
|
||
func safeToolShorthand(cmd *cobra.Command, short string) string {
|
||
short = strings.TrimSpace(short)
|
||
if len(short) != 1 {
|
||
return ""
|
||
}
|
||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||
return ""
|
||
}
|
||
return short
|
||
}
|
||
|
||
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 !canRegisterToolFlag(cmd, primary) {
|
||
continue
|
||
}
|
||
shorthand := safeToolShorthand(cmd, spec.Shorthand)
|
||
alias := strings.TrimSpace(spec.Alias)
|
||
if alias == primary || !canRegisterToolFlag(cmd, alias) {
|
||
alias = ""
|
||
}
|
||
|
||
switch spec.Kind {
|
||
case flagString, flagJSON:
|
||
cmd.Flags().StringP(primary, shorthand, "", usage)
|
||
if alias != "" {
|
||
cmd.Flags().String(alias, "", usage+" (alias)")
|
||
_ = cmd.Flags().MarkHidden(alias)
|
||
}
|
||
case flagInteger:
|
||
cmd.Flags().IntP(primary, shorthand, 0, usage)
|
||
if alias != "" {
|
||
cmd.Flags().Int(alias, 0, usage+" (alias)")
|
||
_ = cmd.Flags().MarkHidden(alias)
|
||
}
|
||
case flagNumber:
|
||
cmd.Flags().Float64P(primary, shorthand, 0, usage)
|
||
if alias != "" {
|
||
cmd.Flags().Float64(alias, 0, usage+" (alias)")
|
||
_ = cmd.Flags().MarkHidden(alias)
|
||
}
|
||
case flagBoolean:
|
||
cmd.Flags().BoolP(primary, 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, 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(), "-")
|
||
}
|