Files
dingtalk-workspace-cli/internal/helpers/helpers.go

876 lines
29 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.
package helpers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Re-export cmdutil functions as package-level aliases so that existing product
// files continue to compile with their current (unexported) call sites.
// This avoids a mass-rename in 22 product files while still consolidating the
// implementations in pkg/cmdutil.
var (
groupRunE = cmdutil.GroupRunE
hintSubCmd = cmdutil.HintSubCmd
mustGetFlag = cmdutil.MustGetFlag
flagOrFallback = cmdutil.FlagOrFallback
mustFlagOrFallback = cmdutil.MustFlagOrFallback
validateRequiredFlags = cmdutil.ValidateRequiredFlags
validateRequiredFlagWithAliases = cmdutil.ValidateRequiredFlagWithAliases
parseISOTimeToMillis = cmdutil.ParseISOTimeToMillis
validateTimeRange = cmdutil.ValidateTimeRange
helperSleep = time.Sleep
helperAfter = time.After
)
// Deps holds shared dependencies injected from the host application.
type Deps struct {
Caller edition.ToolCaller
Out *Formatter
}
// deps is the package-level dependency holder, set during registration.
var deps *Deps
// InitDeps initializes shared dependencies for all product commands.
// Must be called before any product command's RunE executes (typically
// during command tree construction in newLegacyPublicCommands).
func InitDeps(caller edition.ToolCaller) {
deps = &Deps{
Caller: caller,
Out: NewFormatter(),
}
}
// GetFormatter returns the shared output formatter for use by sibling packages.
func GetFormatter() *Formatter {
if deps == nil {
return NewFormatter()
}
return deps.Out
}
// copyFlags copies specified flags from source command to target command.
// This is useful when creating alias commands that reuse another command's RunE.
func copyFlags(src, dst *cobra.Command, flagNames ...string) {
for _, name := range flagNames {
if f := src.Flags().Lookup(name); f != nil {
dst.Flags().AddFlag(f)
}
}
}
// GetCaller returns the shared ToolCaller for use by sibling packages.
func GetCaller() edition.ToolCaller {
if deps == nil {
return nil
}
return deps.Caller
}
// cmdToProduct maps CLI command names to MCP server IDs for direct routing.
var cmdToProduct = map[string]string{
"aitable": "aitable", "calendar": "calendar", "contact": "contact",
"todo": "todo", "doc": "doc", "chat": "chat",
"oa": "oa", "mail": "mail", "ding": "ding",
"devdoc": "devdoc",
"attendance": "attendance",
"live": "live", "aiapp": "aiapp",
"minutes": "minutes",
"finance": "finance",
"report": "report",
"drive": "drive",
"blackboard": "blackboard",
"credit": "credit-ep",
"docparse": "docparse",
"aidesign": "aidesign",
"sheet": "sheet",
"wiki": "wiki",
"aisearch": "aisearch",
"hrbrain": "hrbrain",
"yida": "yida",
// vendor extension command routing (kept here for resolveProductID)
"unified-toolkit": "unified-toolkit",
"outbound-call": "outbound-call",
"discovery": "discovery",
"ai-sincere-hire": "ai-sincere-hire",
"contract": "contract",
"oa-plus": "oa",
"pat": "pat",
"edu-contact": "edu-contact",
"edu-group": "edu-group",
"edu-app": "edu-app",
"agoal": "agoal",
}
// resolveProductID determines the MCP server ID from the CLI args.
// It scans os.Args for the first known product command name.
func resolveProductID() string {
for _, arg := range os.Args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
if pid, ok := cmdToProduct[arg]; ok {
return pid
}
}
return ""
}
// callMCPToolReturnText calls an MCP tool and returns the first text content,
// used when the caller needs to parse the result (e.g. extracting an eventId).
func callMCPToolReturnText(ctx context.Context, toolName string, args map[string]any) (string, error) {
serverID := resolveProductID()
if serverID == "" {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("cannot resolve product for tool %q", toolName),
}
}
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
}
func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
result, err := deps.Caller.CallTool(ctx, serverID, toolName, args)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// CallMCPReadToolTextOnServer performs a read-only lookup needed to construct a
// semantic Shortcut dry-run plan. Under ordinary execution it is identical to
// CallMCPToolTextOnServer. Under --dry-run it requires the host's optional
// ReadToolCaller capability; if unavailable, it fails closed instead of
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
return CallMCPReadToolTextOnServerContext(context.Background(), serverID, toolName, args)
}
// CallMCPReadToolTextOnServerContext is the cancellable form used by Cobra
// commands and composite shortcuts. Keeping the caller context attached to the
// transport lets SIGTERM/parent deadlines stop an in-flight MCP read and return
// a structured error instead of leaving the CLI silent until the host kills it.
func CallMCPReadToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
if ctx == nil {
ctx = context.Background()
}
return callMCPReadToolReturnTextOnServer(ctx, serverID, toolName, args)
}
func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
if !IsReadToolName(toolName) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
}
}
if deps == nil || deps.Caller == nil {
return "", &CLIError{
Code: CodeMCPToolError,
Message: "MCP caller is not initialized",
}
}
if !deps.Caller.DryRun() {
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
}
readCaller, ok := deps.Caller.(edition.ReadToolCaller)
if !ok {
return "", &CLIError{
Code: CodeMCPToolError,
Message: "当前运行时不支持在 --dry-run 下执行只读解析",
}
}
result, err := readCaller.CallReadTool(ctx, serverID, toolName, args)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// IsReadToolName is the fail-closed naming contract for the dry-run read
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
// future direct helper caller cannot accidentally route a write tool through
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
if toolName == "enterprise_person_search" {
return true
}
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(toolName, prefix) {
return true
}
}
return false
}
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
return "", patErr
}
return "", WrapError(err)
}
if result == nil {
return "", &CLIError{
Code: CodeMCPToolError,
Message: "MCP 工具返回 nil result,无法判断操作结果",
Suggestion: "不要把空回复当作成功;请重试并携带 operation/trace 信息排查服务端",
Operation: serverID + "/" + toolName,
}
}
for _, c := range result.Content {
if c.Type == "text" && strings.TrimSpace(c.Text) != "" {
dumpRawToolResponse(serverID, toolName, c.Text)
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return "", &CLIError{
Code: CodeAuthTokenExpired,
Message: c.Text,
Suggestion: authExpiredSuggestion(),
}
}
if isNotLoggedInError(errBody) {
return "", &CLIError{
Code: CodeAuthNotConfigured,
Message: "当前未登录",
Suggestion: notLoggedInSuggestion(),
}
}
if patErr := classifyPATError(errBody); patErr != nil {
return "", patErr
}
if isBusinessError(errBody) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: c.Text,
Suggestion: suggestForBusinessError(errBody),
}
}
}
return c.Text, nil
}
}
// Some legacy tools intentionally use an empty text response as an
// acknowledgement. This low-level parser cannot know the business contract,
// so preserve that representation. Data-returning callers must validate the
// operation-specific shape (for example records/tables/valid) before they
// report success. Orchestrators whose contract requires a business result use
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
return "", nil
}
// CallMCPToolTextOnServer invokes an MCP tool and returns its raw text response
// WITHOUT printing anything, applying the same error classification as the
// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts,
// which chain several tool calls and need each intermediate result as data.
func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
return CallMCPToolTextOnServerContext(context.Background(), serverID, toolName, args)
}
// CallMCPToolTextOnServerContext invokes one MCP tool while preserving the
// command's cancellation and deadline. Legacy callers may continue using
// CallMCPToolTextOnServer, which intentionally retains background context.
func CallMCPToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
if ctx == nil {
ctx = context.Background()
}
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
}
// CallMCPToolDataOnServer invokes one tool without printing and decodes its
// JSON text payload. Framework renderers use this seam so the business request
// is executed exactly once and presentation remains a separate step.
func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (any, error) {
if ctx == nil {
ctx = context.Background()
}
text, err := callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data any
decoder := json.NewDecoder(strings.NewReader(text))
if err := decoder.Decode(&data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("存在多个 JSON 值")
}
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
return data, nil
}
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
func callMCPTool(toolName string, args map[string]any) error {
return callMCPToolContext(context.Background(), toolName, args)
}
func callMCPToolContext(ctx context.Context, toolName string, args map[string]any) error {
return callMCPToolInternalOptsContext(ctx, "", toolName, args, false)
}
// callMCPToolUnescaped 与 callMCPTool 功能相同,但 JSON 输出禁用 HTML 转义。
// 适用于返回值中包含 URL(如 presignedUrl)的接口,避免 & 被转义为 \u0026。
// 当前仅 minutes upload 的三个命令使用此函数。
func callMCPToolUnescaped(toolName string, args map[string]any) error {
return callMCPToolInternalOpts("", toolName, args, true)
}
// callMCPToolOnServer 在指定的 MCP Server 上调用工具,跳过 resolveProductID() 的自动路由。
// 用于需要显式指定 serverID 的场景(如 credit 等多 server 产品)。
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolInternalOptsContext(context.Background(), serverID, toolName, args, false)
}
// CallMCPToolOnServer is the exported version of callMCPToolOnServer for use
// by extension packages that live in separate Go packages.
func CallMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolOnServer(serverID, toolName, args)
}
// CallMCPToolOnServerContext is the cancellable print-path variant for
// Shortcut execution. It preserves the same output and error projection as the
// legacy wrapper while allowing the root signal context to abort transport.
func CallMCPToolOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) error {
return callMCPToolInternalOptsContext(ctx, serverID, toolName, args, false)
}
// GroupRunE is the exported version of groupRunE for use by extension packages.
func GroupRunE(cmd *cobra.Command, args []string) error {
return groupRunE(cmd, args)
}
// MustGetStringFlag retrieves a string flag, falling back to inherited flags.
func MustGetStringFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return val
}
// callMCPToolInternalOpts 是所有 MCP 工具调用的核心实现。
//
// 参数说明:
// - explicitServerID: 显式指定的 MCP Server ID,为空时自动路由
// - toolName: MCP 工具名称(如 "create_upload_session")
// - args: 工具调用参数,会被序列化为 JSON 传给 MCP Server
// - unescapeHTML: 是否禁用 JSON 输出的 HTML 转义(仅影响最终输出格式)
//
// 执行流程:
// 1. DryRun 模式:仅打印工具名和参数,不实际调用
// 2. 调用 MCP Server 获取结果
// 3. 错误分类:网关错误 → 未登录 → PAT 错误 → 业务错误
// 4. 根据 --format 标志选择输出格式(json / table / raw)
func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
return callMCPToolInternalOptsContext(context.Background(), explicitServerID, toolName, args, unescapeHTML)
}
func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
if ctx == nil {
ctx = context.Background()
}
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": toolName,
"arguments": args,
})
}
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
deps.Out.PrintKeyValue("Tool", toolName)
if args != nil {
argsJSON, _ := json.MarshalIndent(args, " ", " ")
deps.Out.PrintKeyValue("Arguments", "\n "+string(argsJSON))
}
return nil
}
// 确定目标 MCP Server:优先使用显式指定的 serverID,否则自动路由
serverID := explicitServerID
if serverID == "" {
serverID = resolveProductID()
}
// 调用 MCP Server
result, err := deps.Caller.CallTool(ctx, serverID, toolName, args)
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
return patErr
}
return WrapErrorWithOperation(err, serverID+"/"+toolName)
}
// 根据 unescapeHTML 选择 JSON 输出函数:
// - false(默认):使用 PrintJSON,& 会被转义为 \u0026
// - true:使用 PrintJSONUnescaped,保留原始字符(适用于含 URL 的返回值)
printJSON := deps.Out.PrintJSON
if unescapeHTML {
printJSON = deps.Out.PrintJSONUnescaped
}
for _, c := range result.Content {
if c.Type == "text" {
dumpRawToolResponse(serverID, toolName, c.Text)
// 尝试将返回文本解析为 JSON,进行错误分类
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
// 网关层错误(如 token 过期)
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
}
// 未登录错误
if isNotLoggedInError(errBody) {
return &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录", Suggestion: notLoggedInSuggestion()}
}
// PAT(个人访问令牌)相关错误
if patErr := classifyPATError(errBody); patErr != nil {
return patErr
}
// 业务逻辑错误
if isBusinessError(errBody) {
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
}
}
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
}
}
// 无 text 类型内容时,将整个 result 对象序列化为 JSON 输出
return printJSON(result)
}
// RenderLegacyMCPText renders an already-fetched MCP text response through the
// exact legacy formatter. It lets dual validation execute the business request
// once, validate a shadow unified result, and still preserve legacy bytes.
func RenderLegacyMCPText(toolName, text string) error {
return renderLegacyMCPText(toolName, text, false)
}
func renderLegacyMCPText(toolName, text string, unescapeHTML bool) error {
printJSON := deps.Out.PrintJSON
if unescapeHTML {
printJSON = deps.Out.PrintJSONUnescaped
}
flagFormat := deps.Caller.Format()
if flagFormat == "json" {
var parsed any
if err := json.Unmarshal([]byte(text), &parsed); err == nil {
return printJSON(parsed)
}
}
if toolName == "search_open_platform_docs" && flagFormat == "table" {
if formatted := formatDevdocSearchTable(text); formatted {
return nil
}
}
if unescapeHTML {
var parsed any
if err := json.Unmarshal([]byte(text), &parsed); err == nil {
return printJSON(parsed)
}
}
deps.Out.PrintRaw(text)
return nil
}
// dumpRawToolResponse emits one opt-in lower-layer record for live projection
// audits. Responses may contain business data, so normal CLI runs never emit
// this line; an auditor must explicitly set DWS_DUMP_RAW.
func dumpRawToolResponse(serverID, toolName, text string) {
if strings.TrimSpace(os.Getenv("DWS_DUMP_RAW")) == "" {
return
}
fmt.Fprintln(os.Stderr, formatRawDumpLine(serverID, toolName, text))
}
func formatRawDumpLine(serverID, toolName, text string) string {
raw := json.RawMessage(strings.TrimSpace(text))
if json.Valid(raw) {
var compact bytes.Buffer
_ = json.Compact(&compact, raw)
raw = compact.Bytes()
} else {
encoded, _ := json.Marshal(text)
raw = encoded
}
return "DWSRAW\t" + serverID + "\t" + toolName + "\t" + string(raw)
}
// formatDevdocSearchTable formats devdoc search JSON results as a table.
// Returns true on success, false if the JSON cannot be parsed.
func formatDevdocSearchTable(raw string) bool {
var resp struct {
Result struct {
Items []struct{ Title, URL string }
CurrentPage int `json:"currentPage"`
TotalCount int `json:"totalCount"`
HasMore bool `json:"hasMore"`
}
}
if json.Unmarshal([]byte(raw), &resp) != nil {
return false
}
items := resp.Result.Items
if len(items) == 0 {
deps.Out.PrintInfo("no matching documents")
return true
}
headers := []string{"标题", "URL"}
rows := make([][]string, len(items))
for i, it := range items {
title := stripHTMLEm(it.Title)
rows[i] = []string{title, it.URL}
}
deps.Out.PrintTable(headers, rows)
pageInfo := fmt.Sprintf("page %d, total %d", resp.Result.CurrentPage, resp.Result.TotalCount)
if resp.Result.HasMore {
pageInfo += ", use --page " + fmt.Sprintf("%d", resp.Result.CurrentPage+1) + " for more"
}
deps.Out.PrintDim(pageInfo)
return true
}
// stripHTMLEm removes <em></em> tags, keeping inner text.
func stripHTMLEm(s string) string {
s = strings.ReplaceAll(s, "<em>", "")
s = strings.ReplaceAll(s, "</em>", "")
return s
}
// getCurrentUserID fetches the current user's userId via the contact MCP server.
func getCurrentUserID(ctx context.Context) (string, error) {
result, err := deps.Caller.CallTool(ctx, "contact", "get_current_user_profile", nil)
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
for _, c := range result.Content {
if c.Type != "text" || c.Text == "" {
continue
}
var data struct {
Result []struct {
OrgEmployeeModel struct {
UserID string `json:"userId"`
} `json:"orgEmployeeModel"`
} `json:"result"`
}
if json.Unmarshal([]byte(c.Text), &data) == nil && len(data.Result) > 0 && data.Result[0].OrgEmployeeModel.UserID != "" {
return data.Result[0].OrgEmployeeModel.UserID, nil
}
var data2 struct {
Result struct {
UserID string `json:"userId"`
} `json:"result"`
}
if json.Unmarshal([]byte(c.Text), &data2) == nil && data2.Result.UserID != "" {
return data2.Result.UserID, nil
}
var flat map[string]any
if json.Unmarshal([]byte(c.Text), &flat) == nil {
if arr, ok := flat["result"].([]any); ok && len(arr) > 0 {
if m, ok := arr[0].(map[string]any); ok {
if oem, ok := m["orgEmployeeModel"].(map[string]any); ok {
if uid, ok := oem["userId"].(string); ok && uid != "" {
return uid, nil
}
}
}
}
}
}
return "", fmt.Errorf("cannot parse userId from get_current_user_profile response")
}
// classifyPATError checks if a parsed JSON body contains a PAT permission error code.
// Returns a *PATError if matched, nil otherwise.
func classifyPATError(body map[string]any) error {
for _, key := range []string{"code", "errorCode"} {
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
}
return nil
}
// reclassifyPATFromError inspects an error returned by the framework (which may
// have classified a PAT response as a generic business error) and converts it
// to a *PATError if the error message contains a known PAT permission code.
func reclassifyPATFromError(err error) error {
if _, ok := err.(*PATError); ok {
return err
}
msg := err.Error()
for code := range patNoPermissionCodes {
if strings.Contains(msg, code) {
return &PATError{RawJSON: buildMinimalPATJSON(code)}
}
}
return nil
}
func buildMinimalPATJSON(code string) string {
out := map[string]any{"success": false, "code": code}
b, _ := json.MarshalIndent(out, "", " ")
return string(b)
}
// isBusinessError checks if a parsed JSON body represents a business-level error.
func isBusinessError(body map[string]any) bool {
if v, ok := body["error"]; ok {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return true
}
case map[string]any:
if len(t) > 0 {
return true
}
case []any:
if len(t) > 0 {
return true
}
default:
if t != nil {
return true
}
}
}
if v, ok := body["status"].(string); ok && strings.EqualFold(strings.TrimSpace(v), "error") {
return true
}
for _, key := range []string{"errorCode", "error_code", "errcode", "err_code", "code"} {
if isErrorCodeValue(body[key]) {
return true
}
}
if v, ok := body["success"].(bool); ok && !v {
return true
}
if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") {
return true
}
return false
}
func isErrorCodeValue(v any) bool {
switch t := v.(type) {
case string:
code := strings.TrimSpace(t)
if code == "" {
return false
}
switch strings.ToLower(code) {
case "0", "ok", "success", "succeed":
return false
default:
return true
}
case float64:
return t != 0
case int:
return t != 0
case int64:
return t != 0
case json.Number:
return strings.TrimSpace(t.String()) != "" && t.String() != "0"
default:
return false
}
}
// isNotLoggedInError checks if the error body indicates missing authentication.
func isNotLoggedInError(body map[string]any) bool {
if errMsg, ok := body["error"].(string); ok {
if strings.Contains(errMsg, "Missing service_id or access_key") {
return true
}
}
return false
}
// notLoggedInSuggestion returns a mode-aware hint for not-logged-in errors.
func notLoggedInSuggestion() string {
if edition.Get().IsEmbedded {
return "请先登录"
}
return "请先登录:dws auth login"
}
// authExpiredSuggestion returns a mode-aware hint for auth errors.
func authExpiredSuggestion() string {
if edition.Get().IsEmbedded {
return "Auth expired, re-run previous command (max 2 retries)"
}
return "Re-authenticate: dws auth login"
}
// dwsGatewayErrors is the set of DWS gateway-level auth error codes that
// must be surfaced as CodeAuthTokenExpired so the runner / overlay knows the
// current bearer token has been rejected.
//
// TOKEN_VERIFIED_FAILED / USER_TOKEN_ILLEGAL come from upstream DingTalk
// services that pass through the gateway; they are server-side rejections
// of an otherwise locally-valid token.
var dwsGatewayErrors = map[string]bool{
"DWS_SERVICE_UNAUTHORIZED": true,
"DWS_AUTH_SERVICE_FAILED": true,
"TOKEN_VERIFIED_FAILED": true,
"USER_TOKEN_ILLEGAL": true,
}
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody.
//
// Field-name coverage: the gateway and upstream services are inconsistent
// about which key carries the code — we have observed all of "errorCode",
// "error_code" and "code" in the wild — so check all three. The transport
// layer's ExtractServerDiagnosticsFromMap normalises into ServerErrorCode,
// but ClassifyToolResultContent is fed the raw content map and must do its
// own lookup.
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
for _, key := range []string{"errorCode", "error_code", "code"} {
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
return code, true
}
}
return "", false
}
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return suggestForBusinessErrorText(msg)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
// checks os.Args for --yes/-y (for callers that don't pass *cobra.Command).
func confirmDelete(resourceType, resourceName string) bool {
for _, arg := range os.Args[1:] {
if arg == "--yes" || arg == "-y" {
return true
}
}
warning := color.New(color.FgRed, color.Bold)
warning.Printf("About to delete %s: %s\n", resourceType, resourceName)
fmt.Print("Confirm deletion? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
deps.Out.PrintInfo("Operation cancelled")
return false
}
// confirmDangerousAction confirms a high-impact action whose semantics are
// not deletion. Keeping this separate from confirmDelete prevents enable,
// disable, or publication operations from being described as deletes.
func confirmDangerousAction(cmd *cobra.Command, action, resourceName string) bool {
if cmd == nil {
return false
}
if yes, err := cmd.Flags().GetBool("yes"); err == nil && yes {
return true
}
output := cmd.ErrOrStderr()
fmt.Fprintf(output, "About to %s: %s\n", action, resourceName)
fmt.Fprint(output, "Confirm action? (yes/no): ")
reader := bufio.NewReader(cmd.InOrStdin())
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
fmt.Fprintln(output, "Operation cancelled")
return false
}
// toCamelCase converts a kebab-case string to camelCase.
// Examples: "base-id" -> "baseId", "open-dingtalk-id" -> "openDingtalkId"
func toCamelCase(kebab string) string {
parts := strings.Split(kebab, "-")
if len(parts) <= 1 {
return kebab
}
for i := 1; i < len(parts); i++ {
if len(parts[i]) > 0 {
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
}
return strings.Join(parts, "")
}
// RegisterCamelCaseAliases recursively walks the command tree and registers
// hidden camelCase aliases for every kebab-case flag. This prevents flag-value
// prefix matching from misinterpreting AI-generated camelCase flags
// (e.g. --baseId) as a short flag + glued value (--base + "Id").
func RegisterCamelCaseAliases(cmd *cobra.Command) {
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !strings.Contains(f.Name, "-") {
return
}
camel := toCamelCase(f.Name)
if camel == f.Name || cmd.Flags().Lookup(camel) != nil {
return
}
switch f.Value.Type() {
case "int", "int64":
cmd.Flags().Int(camel, 0, "")
case "float64":
cmd.Flags().Float64(camel, 0, "")
case "bool":
cmd.Flags().Bool(camel, false, "")
case "stringSlice":
cmd.Flags().StringSlice(camel, nil, "")
default:
cmd.Flags().String(camel, "", "")
}
_ = cmd.Flags().MarkHidden(camel)
})
for _, child := range cmd.Commands() {
RegisterCamelCaseAliases(child)
}
}