Files
dingtalk-workspace-cli/internal/shortcut/runner.go
玉澜 ea43db1d64 refactor(schema): drop cli shim packages, add testseam swap, consolidate ForTest helpers
- import 统一:删除 cli/contractfinal 与 cli/runtimeannotate 垫片包,
  26 个消费文件一律直引 corecmd/*;cli 根仅保留 runtime_schema_seam.go
  包内别名,依赖图保持单向无环
- 新增 internal/testseam.Swap[T]:包级 var 注入缝置换由 t.Cleanup
  结构性恢复;迁移 pipeline*/stdinIsTerminalFn/loadReviewedCommandRegistry/
  schemaCommandCatalogError/schemaParameterBindingData/finalSchemaAgentMetadata
  六组核心缝(26+ 处)
- ForTest 辅助归拢到 per-package fortest.go(corecmd/contract、
  corecmd/contractfinal、shortcut、cli),生产文件只留真逻辑
- 文档同步:runtime_schema_seam.go / runtimeannotate/doc.go 注释、
  CHANGELOG、RFC §278-279、AGENTS.md
2026-08-03 00:50:16 +08:00

280 lines
9.6 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 (
"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
}