// 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 ( "encoding/json" "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" 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. // 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 through the unified command // path. FromShortcut expands the legacy Risk only when Safety is absent; when // Safety is explicit the same value drives both ConfirmSafety and ContractFinal. func mount(s Shortcut) *cobra.Command { cmd := corecmd.New(FromShortcut(s)) // Preserve the historical Shortcut help surface: Tips, rather than Agent // selection examples, own cobra's Example block. The Schema declaration still // carries its reviewed examples in ContractFinal. cmd.Example = shortcutExamples(s.Tips) return cmd } 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 hasNonEmptyString(values []string) bool { for _, value := range values { if strings.TrimSpace(value) != "" { return true } } return false } // 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 }