Files
瑞达 ab2fba868b fix(chat): improve aligned shortcut reliability
Add hidden compatibility flags and exact high-frequency routes to avoid help-driven recovery loops. Restore granular PAT tests and correct the Runtime Catalog versus curated Schema contract.
2026-08-03 11:53:45 +08:00

526 lines
17 KiB
Go
Raw Permalink 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 shortcut
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RuntimeContext is handed to a Shortcut's Validate and Execute hooks. It wraps
// the cobra command and exposes typed flag accessors plus a single CallMCP entry
// point so shortcut authors never touch cobra/executor plumbing directly.
type RuntimeContext struct {
cmd *cobra.Command
shortcut Shortcut
}
// AIMessageTagFlag matches `chat message send --ai-tag`: IM send shortcuts
// default to tagging delivered messages as AI-sent, while still allowing users
// to opt out with --ai-tag=false.
func AIMessageTagFlag() Flag {
return Flag{Name: "ai-tag", Type: FlagBool, Default: "true", Desc: "消息是否带 AI 发送角标(默认 true)"}
}
// Command returns the underlying cobra command (escape hatch; prefer the typed
// accessors below).
func (rt *RuntimeContext) Command() *cobra.Command { return rt.cmd }
// Str returns the trimmed string value of a flag, or "" if unset.
func (rt *RuntimeContext) Str(name string) string {
v, _ := rt.cmd.Flags().GetString(name)
return strings.TrimSpace(v)
}
// StrFirst returns the first non-empty string value across a primary flag and
// its aliases.
func (rt *RuntimeContext) StrFirst(names ...string) string {
for _, name := range names {
if v := rt.Str(name); v != "" {
return v
}
}
return ""
}
// Bool returns the bool value of a flag.
func (rt *RuntimeContext) Bool(name string) bool {
v, _ := rt.cmd.Flags().GetBool(name)
return v
}
// Int returns the int value of a flag.
func (rt *RuntimeContext) Int(name string) int {
v, _ := rt.cmd.Flags().GetInt(name)
return v
}
// IntFirst returns the int value for a primary flag plus aliases. Explicitly set
// aliases are considered before the primary's default, matching native helper
// compatibility flags such as --size for --limit.
func (rt *RuntimeContext) IntFirst(primary string, aliases ...string) int {
for _, alias := range aliases {
f := rt.cmd.Flags().Lookup(alias)
if f != nil && f.Changed {
return rt.Int(alias)
}
}
return rt.Int(primary)
}
// StrSlice returns the string-slice value of a flag.
func (rt *RuntimeContext) StrSlice(name string) []string {
v, _ := rt.cmd.Flags().GetStringSlice(name)
return v
}
// StrSliceFirst returns the first non-empty string-slice value across a
// primary flag and its compatibility aliases.
func (rt *RuntimeContext) StrSliceFirst(names ...string) []string {
for _, name := range names {
if v := rt.StrSlice(name); hasNonEmptyString(v) {
return v
}
}
return nil
}
// Changed reports whether the user explicitly set the flag on the command line.
func (rt *RuntimeContext) Changed(name string) bool {
f := rt.cmd.Flags().Lookup(name)
return f != nil && f.Changed
}
// DryRun reports whether --dry-run is set (inherited from the root command).
func (rt *RuntimeContext) DryRun() bool { return globalBool(rt.cmd, "dry-run") }
// Yes reports whether --yes is set (skip confirmation prompts).
func (rt *RuntimeContext) Yes() bool { return globalBool(rt.cmd, "yes") }
// AddAIMessageTag attaches the clawType parameter expected by IM send APIs when
// the shortcut exposes --ai-tag and the flag is true. This mirrors the native
// `chat message send` behavior.
func (rt *RuntimeContext) AddAIMessageTag(params map[string]any) map[string]any {
if params == nil {
params = map[string]any{}
}
if f := rt.cmd.Flags().Lookup("ai-tag"); f == nil || rt.Bool("ai-tag") {
params["clawType"] = edition.ClawType()
}
return params
}
// CallMCP dispatches a single MCP tool call and prints the result, reusing the
// shared helper path so the shortcut inherits DWS's error classification
// (auth/PAT/business), dry-run preview and --format/--jq/--fields output for
// free. The MCP server id is the shortcut's product (defaults to Service).
//
// Most passthrough shortcuts do all their work in one CallMCP call.
func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error {
if params == nil {
params = map[string]any{}
}
return helpers.CallMCPToolOnServer(rt.shortcut.product(), tool, params)
}
// CallMCPData dispatches a read-only tool call to an explicit MCP product and
// returns the PARSED response as data WITHOUT printing. This is the building
// block for multi-step ("smart") shortcuts: call a tool, read its output, feed
// it into the next call. Errors carry DWS's auth/PAT/business classification.
//
// The product is explicit (not the shortcut's own) because smart shortcuts
// routinely cross services — e.g. resolve a name via `contact` then act via
// `chat`. Reads run even under --dry-run so a preview can still resolve inputs.
// Write tools that need parsed responses must use CallMCPWriteData instead.
// Under --dry-run this path fails closed unless the tool name belongs to the
// narrow read-only naming contract used by the current MCP registry.
func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
if !looksReadTool(tool) {
return nil, dryRunWriteError(product, tool)
}
return rt.callMCPReadData(product, tool, params)
}
return rt.callMCPData(product, tool, params)
}
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
// accidentally perform writes while rendering a preview.
func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
return nil, dryRunWriteError(product, tool)
}
return rt.callMCPData(product, tool, params)
}
func dryRunWriteError(product, tool string) error {
return apperrors.NewValidation(fmt.Sprintf(
"--dry-run 下禁止执行未明确分类为只读的工具 %s/%s;请在 shortcut 中输出 preview 后返回",
product, tool))
}
func looksReadTool(tool string) bool {
return helpers.IsReadToolName(tool)
}
func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]any) (map[string]any, error) {
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var out map[string]any
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return out, nil
}
func (rt *RuntimeContext) callMCPReadData(product, tool string, params map[string]any) (map[string]any, error) {
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var out map[string]any
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return out, nil
}
// Output prints a (typically reshaped/projected) payload honouring the root
// --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean,
// composed result instead of the raw MCP response — the output-projection
// output-formatting capability.
func (rt *RuntimeContext) Output(payload any) error {
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
}
// mount compiles a Shortcut into a cobra command.
func mount(s Shortcut) *cobra.Command {
cmd := &cobra.Command{
Use: s.Command,
Short: s.Description,
Long: shortcutLongHelp(s),
Hidden: s.Hidden,
}
if len(s.Tips) > 0 {
cmd.Example = " " + strings.Join(s.Tips, "\n ")
}
registerFlags(cmd, s.Flags)
annotateRuntimeSchemaContract(cmd, s)
cmd.RunE = func(c *cobra.Command, _ []string) error {
rt := &RuntimeContext{cmd: c, shortcut: s}
if err := validateFlags(rt, s); err != nil {
return err
}
if err := validateConstraints(rt, s); err != nil {
return err
}
if s.Validate != nil {
if err := s.Validate(rt); err != nil {
return err
}
}
if !confirmRisk(rt, s) {
return nil
}
if s.Execute == nil {
return apperrors.NewInternal(fmt.Sprintf("shortcut %s %s 未实现 Execute", s.Service, s.Command))
}
return s.Execute(rt)
}
return cmd
}
// annotateRuntimeSchemaContract projects the declarative shortcut invocation
// contract onto its real Cobra leaf. Stable identity still comes exclusively
// from the reviewed CommandRegistry; these annotations only preserve parameter
// and constraint facts that Cobra cannot represent by itself.
func annotateRuntimeSchemaContract(cmd *cobra.Command, s Shortcut) {
publicFlags := make(map[string]bool, len(s.Flags))
requiredFlags := make([]string, 0)
for _, flag := range s.Flags {
if flag.Hidden {
continue
}
publicFlags[flag.Name] = true
if flag.Required {
requiredFlags = append(requiredFlags, flag.Name)
}
if len(flag.Enum) > 0 {
cli.AnnotateRuntimeFlagEnum(cmd, flag.Name, flag.Enum...)
}
}
var constraints cli.RuntimeSchemaConstraints
for _, constraint := range s.Constraints {
flags := make([]string, 0, len(constraint.Flags))
for _, flagName := range constraint.Flags {
if publicFlags[flagName] {
flags = append(flags, flagName)
}
}
switch constraint.Kind {
case ConstraintAtLeastOne:
if len(flags) == 1 {
requiredFlags = append(requiredFlags, flags[0])
} else if len(flags) > 1 {
constraints.RequireOneOf = append(constraints.RequireOneOf, flags)
}
case ConstraintExactlyOne:
if len(flags) == 1 {
requiredFlags = append(requiredFlags, flags[0])
} else if len(flags) > 1 {
constraints.RequireOneOf = append(constraints.RequireOneOf, flags)
constraints.MutuallyExclusive = append(constraints.MutuallyExclusive, flags)
}
case ConstraintMutuallyExclusive:
if len(flags) > 1 {
constraints.MutuallyExclusive = append(constraints.MutuallyExclusive, flags)
}
}
}
cli.AnnotateRuntimeRequiredFlags(cmd, requiredFlags...)
cli.AnnotateRuntimeConstraints(cmd, constraints)
}
// registerFlags declares each Flag on the command with its type/default/desc.
func registerFlags(cmd *cobra.Command, flags []Flag) {
for _, f := range flags {
desc := flagHelp(f)
switch f.Type {
case FlagBool:
cmd.Flags().Bool(f.Name, f.Default == "true", desc)
case FlagInt:
cmd.Flags().Int(f.Name, atoiDefault(f.Default), desc)
case FlagStringSlice:
var defaults []string
if value := strings.TrimSpace(f.Default); value != "" {
defaults = strings.Split(value, ",")
}
cmd.Flags().StringSlice(f.Name, defaults, desc)
default: // FlagString and empty
cmd.Flags().String(f.Name, f.Default, desc)
}
if f.Hidden {
_ = cmd.Flags().MarkHidden(f.Name)
}
}
}
func flagHelp(f Flag) string {
parts := make([]string, 0, 2)
if f.Required && !strings.Contains(f.Desc, "必填") {
parts = append(parts, "必填")
}
if len(f.Enum) > 0 {
parts = append(parts, "可选值: "+strings.Join(f.Enum, ", "))
}
if len(parts) == 0 {
return f.Desc
}
if strings.TrimSpace(f.Desc) == "" {
return strings.Join(parts, ";")
}
return f.Desc + "(" + strings.Join(parts, ";") + ")"
}
func shortcutLongHelp(s Shortcut) string {
long := strings.TrimSpace(s.Intent)
if long == "" {
long = strings.TrimSpace(s.Description)
}
if len(s.Constraints) == 0 {
return long
}
publicFlags := make(map[string]bool, len(s.Flags))
for _, flag := range s.Flags {
publicFlags[flag.Name] = !flag.Hidden
}
lines := make([]string, 0, len(s.Constraints))
for _, constraint := range s.Constraints {
visible := constraint
visible.Flags = make([]string, 0, len(constraint.Flags))
for _, flag := range constraint.Flags {
if publicFlags[flag] {
visible.Flags = append(visible.Flags, flag)
}
}
if len(visible.Flags) == 0 ||
(visible.Kind != ConstraintCustom && len(visible.Flags) < 2) {
continue
}
lines = append(lines, " - "+constraintHelp(visible))
}
if len(lines) == 0 {
return long
}
return long + "\n\n参数约束:\n" + strings.Join(lines, "\n")
}
func constraintHelp(constraint Constraint) string {
if strings.TrimSpace(constraint.Description) != "" {
return constraint.Description
}
switch constraint.Kind {
case ConstraintAtLeastOne:
return fmt.Sprintf("%s 至少指定一个", dashed(constraint.Flags))
case ConstraintExactlyOne:
return fmt.Sprintf("%s 必须且只能指定一个", dashed(constraint.Flags))
case ConstraintMutuallyExclusive:
return fmt.Sprintf("%s 互斥,最多指定一个", dashed(constraint.Flags))
default:
return fmt.Sprintf("%s 使用未识别的约束类型 %q", dashed(constraint.Flags), constraint.Kind)
}
}
// validateFlags enforces the declarative Required and Enum constraints.
func validateFlags(rt *RuntimeContext, s Shortcut) error {
for _, f := range s.Flags {
if f.Required && !rt.Changed(f.Name) {
return apperrors.NewValidation(fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc))
}
if f.Required && rt.Changed(f.Name) {
switch f.Type {
case FlagStringSlice:
if !hasNonEmptyString(rt.StrSlice(f.Name)) {
return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", f.Name))
}
case FlagString, "":
if rt.Str(f.Name) == "" {
return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", f.Name))
}
}
}
if len(f.Enum) > 0 && rt.Changed(f.Name) {
values := []string{rt.Str(f.Name)}
if f.Type == FlagStringSlice {
values = rt.StrSlice(f.Name)
}
for _, val := range values {
val = strings.TrimSpace(val)
if !contains(f.Enum, val) {
return apperrors.NewValidation(fmt.Sprintf(
"参数 --%s 取值 %q 不合法,允许值:%s", f.Name, val, strings.Join(f.Enum, ", ")))
}
}
}
}
return nil
}
func hasNonEmptyString(values []string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func validateConstraints(rt *RuntimeContext, s Shortcut) error {
for _, constraint := range s.Constraints {
if len(constraint.Flags) == 0 {
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s 的约束 %q 未声明参数", s.Service, s.Command, constraint.Kind))
}
switch constraint.Kind {
case ConstraintAtLeastOne:
if err := rt.AtLeastOne(constraint.Flags...); err != nil {
return err
}
case ConstraintExactlyOne:
if err := rt.ExactlyOne(constraint.Flags...); err != nil {
return err
}
case ConstraintMutuallyExclusive:
if err := rt.MutuallyExclusive(constraint.Flags...); err != nil {
return err
}
case ConstraintCustom:
if strings.TrimSpace(constraint.Description) == "" {
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s 的 custom 约束缺少描述", s.Service, s.Command))
}
default:
return apperrors.NewInternal(fmt.Sprintf(
"shortcut %s %s 使用未知约束类型 %q", s.Service, s.Command, constraint.Kind))
}
}
return nil
}
// confirmRisk prompts before a write/high-risk-write shortcut unless --yes or
// --dry-run is set. Read-only shortcuts never prompt. Returns false when the
// user declines.
func confirmRisk(rt *RuntimeContext, s Shortcut) bool {
risk := s.risk()
if risk == RiskRead || rt.DryRun() || rt.Yes() {
return true
}
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, risk)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "yes" || answer == "y"
}
// globalBool reads a bool flag that may live on the command, inherited flags, or
// the root's persistent flags (e.g. --dry-run/--yes injected at root).
func globalBool(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
sets := []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()}
if root := cmd.Root(); root != nil {
sets = append(sets, root.PersistentFlags())
}
for _, set := range sets {
if set == nil {
continue
}
if f := set.Lookup(name); f != nil {
if v, err := set.GetBool(name); err == nil {
return v
}
}
}
return false
}