Files
修雨 833d0cc05e Merge main into bugfix-im-shortcut-ai-tag
Resolve the shortcut catalog constraint migration and preserve both fake caller response modes.
2026-07-17 10:37:55 +08:00

427 lines
14 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"
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
}
// 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; as
// a backstop, obvious write-like tool names are rejected here under --dry-run.
func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() && looksWriteTool(tool) {
return nil, dryRunWriteError(product, tool)
}
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 looksWriteTool(tool string) bool {
tool = strings.TrimSpace(strings.ToLower(tool))
for _, prefix := range []string{
"add_", "append_", "approve_", "archive_", "cancel_", "create_",
"delete_", "disable_", "enable_", "grant_", "import_", "insert_",
"invite_", "move_", "publish_", "reject_", "remove_", "replace_",
"respond", "revoke_", "send_", "set_", "submit_", "update_",
"upload_", "write_",
} {
if strings.HasPrefix(tool, prefix) {
return true
}
}
return false
}
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
}
// 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)
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
}
// 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:
cmd.Flags().StringSlice(f.Name, nil, 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
}
lines := make([]string, 0, len(s.Constraints))
for _, constraint := range s.Constraints {
lines = append(lines, " - "+constraintHelp(constraint))
}
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 {
if s.risk() == RiskRead || rt.DryRun() || rt.Yes() {
return true
}
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.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
}