fix: add bounded command typo guidance

This commit is contained in:
chichuan
2026-08-25 00:49:59 +08:00
parent 8d34acbb38
commit ea4a463956
55 changed files with 1015 additions and 387 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
+2
View File
@@ -24,6 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -43,6 +44,7 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(group)
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
+15 -10
View File
@@ -935,25 +935,30 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
utilityGroup := func(command *cobra.Command) *cobra.Command {
cmdutil.MarkGroup(command)
return command
}
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
utilityGroup(newAuthCommand(patCaller)),
utilityGroup(newProfileCommand()),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
utilityGroup(newSkillCommand()),
utilityGroup(newCacheCommand()),
newCatalogCommand(),
newConfigCommand(),
utilityGroup(newConfigCommand()),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(flags),
newAuditCommand(),
utilityGroup(newRecoveryCommand()),
utilityGroup(newEventCommand(flags)),
utilityGroup(newAuditCommand()),
newCompletionCommand(root),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
usage.NewShortcutCommand(),
utilityGroup(usage.NewShortcutCommand()),
schemaCmd,
mcpCmd,
utilityGroup(mcpCmd),
}
root.AddCommand(utilityCommands...)
+4 -2
View File
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
}
}
+8 -8
View File
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
// ──────────────────────────────────────────────────────────
func newAgoalCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "agoal",
Short: "Agoal 管理",
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
@@ -39,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
dws agoal obj-template list 获取目标模板列表
dws agoal obj-template create-or-update 新增或更新目标模板`,
RunE: groupRunE,
}
})
// ── strategy: 战略解码管理 ──────────────────────────────────
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
strategyListCmd := &cobra.Command{
Use: "list",
@@ -139,7 +139,7 @@ scopeType 支持:
// ── contract: 经营合约管理 ──────────────────────────────────
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
contractListCmd := &cobra.Command{
Use: "list",
@@ -258,7 +258,7 @@ scopeType 支持:
// ── scorecard: 计分卡管理 ───────────────────────────────────
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
scorecardDetailCmd := &cobra.Command{
Use: "detail",
@@ -394,7 +394,7 @@ scopeType 支持:
// ── user: 用户目标管理 ──────────────────────────────────────
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
userRulesCmd := &cobra.Command{
Use: "rules",
@@ -445,7 +445,7 @@ scopeType 支持:
// ── report: 周月报管理 ──────────────────────────────────────
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
reportListStatisticsCmd := &cobra.Command{
Use: "list-statistics",
@@ -520,7 +520,7 @@ scopeType 支持:
// ── template: 目标模板管理 ──────────────────────────────────
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
objTemplateListCmd := &cobra.Command{
Use: "list",
+1
View File
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
return groupRunE(cmd, args)
},
}
markGroup(root)
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
+26 -24
View File
@@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "aitable",
Short: "AI 表格操作",
Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。
@@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command {
dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`,
RunE: groupRunE,
SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos
}
})
// ── base: Base 管理 ─────────────────────────────────────────
baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}
baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE})
baseGetPrimaryDocIdCmd := &cobra.Command{
Use: "get-primary-doc-id",
@@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
// ── table: 数据表管理 ───────────────────────────────────────
tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}
tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE})
tableGetCmd := &cobra.Command{
Use: "get",
@@ -2223,7 +2223,7 @@ config 结构参考:
// ── field: 字段管理 ─────────────────────────────────────────
fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}
fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE})
fieldGetCmd := &cobra.Command{
Use: "get",
@@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。
// ── record: 记录管理 ────────────────────────────────────────
recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}
recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE})
recordQueryCmd := &cobra.Command{
Use: "query",
@@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── template: 模板搜索 ──────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE})
templateSearchCmd := &cobra.Command{
Use: "search",
@@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── attachment: 附件管理 ──────────────────────────────────────
attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE})
attachmentUploadCmd := &cobra.Command{
Use: "upload",
@@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── view: 视图管理 ───────────────────────────────────────────
viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}
viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE})
viewGetCmd := &cobra.Command{
Use: "get",
@@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
return callAitableTool("get_views", toolArgs)
},
}
markGroup(viewGetCmd)
// ─── view get <attr> 子命令:按属性投影 view 响应 ──────────────
// card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。
@@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。
return callAitableTool("update_view", toolArgs)
},
}
markGroup(viewUpdateCmd)
// ─── view update <attr> 子命令:按属性局部更新 ────────────────────
@@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form: 表单管理 ──────────────────────────────────────────
formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}
formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}
formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}
formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE})
formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE})
formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE})
formListCmd := &cobra.Command{
Use: "list",
@@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ──
formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}
formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE})
formQuestionsCreateCmd := &cobra.Command{
Use: "create",
@@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── workflow: 自动化工作流管理 ────────────────────────────────
workflowCmd := &cobra.Command{
workflowCmd := newGroupCommand(&cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
RunE: groupRunE,
}
})
workflowCreateCmd := &cobra.Command{
Use: "create",
@@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
// ── dashboard: 仪表盘管理 ────────────────────────────────────
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE})
dashboardConfigExampleCmd := &cobra.Command{
Use: "config-example",
@@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── dashboard share: 仪表盘分享管理 ────────────────────────────
dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}
dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE})
dashboardShareGetCmd := &cobra.Command{
Use: "get",
@@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart: 图表管理 ──────────────────────────────────────────
chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}
chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE})
chartWidgetsExampleCmd := &cobra.Command{
Use: "widgets-example",
@@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart share: 图表分享管理 ────────────────────────────────
chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}
chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE})
chartShareGetCmd := &cobra.Command{
Use: "get",
@@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── export / import: 数据导入导出 ────────────────────────────
exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}
exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE})
exportDataCmd := &cobra.Command{
Use: "data",
@@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with
},
})
importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}
importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE})
// ── advperm: 高级权限 / 自定义角色 ────────────────────────────
advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}
advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE})
advpermEnableCmd := &cobra.Command{
Use: "enable",
@@ -7392,7 +7394,7 @@ role-get 自行 merge)。
// ── section: 文件夹与节点管理(导航树组织) ──────────────────────────────
sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}
sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE})
sectionCreateCmd := &cobra.Command{
Use: "create",
@@ -8424,7 +8426,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
+23 -23
View File
@@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "attendance",
Short: "考勤打卡 / 排班 / 统计",
Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。
@@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command {
globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知)
vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`,
RunE: groupRunE,
}
})
// ── record ───────────────────────────────────────────────
attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}
attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE})
attendanceRecordGetCmd := &cobra.Command{
Use: "get",
@@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command {
// ── check ────────────────────────────────────────────────
attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}
attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE})
// MCP tool: query_check_result
attendanceCheckResultCmd := &cobra.Command{
@@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command {
// ── approve ────────────────────────────────────────────────
attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}
attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE})
// 审批类型关键词到 bizType 数字映射
// 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类),
@@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command {
// ── shift ────────────────────────────────────────────────
attendanceShiftCmd := &cobra.Command{
attendanceShiftCmd := newGroupCommand(&cobra.Command{
Use: "shift",
Short: "班次查询",
Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。
返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`,
RunE: groupRunE,
}
})
// MCP tool: batch_get_employee_shifts
attendanceShiftListCmd := &cobra.Command{
@@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command {
// ── class ────────────────────────────────────────────────
attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}
attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE})
// MCP tool: get_class_list
attendanceClassSearchCmd := &cobra.Command{
@@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── adjustment-rule ────────────────────────────────────
attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}
attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE})
// MCP tool: get_adjustment_rule_detail
attendanceAdjustmentGetCmd := &cobra.Command{
@@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── overtime-rule ──────────────────────────────────────
attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}
attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE})
// MCP tool: get_overtime_rule_detail
attendanceOvertimeGetCmd := &cobra.Command{
@@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── group ──────────────────────────────────────────────
attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}
attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE})
// MCP tool: get_simple_groups
attendanceGroupSearchCmd := &cobra.Command{
@@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── selfsetting ─────────────────────────────────────────────
attendanceSelfSettingCmd := &cobra.Command{
attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{
Use: "selfsetting",
Short: "个人规则设置",
Long: `个人规则设置相关命令。
@@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_self_setting
attendanceSelfSettingGetCmd := &cobra.Command{
@@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── globalsetting ────────────────────────────────────────
attendanceGlobalSettingCmd := &cobra.Command{
attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{
Use: "globalsetting",
Short: "全局规则设置(仅管理员)",
Long: `全局规则设置相关命令,仅管理员可以调用。
@@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_global_setting
attendanceGlobalSettingGetCmd := &cobra.Command{
@@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── report ──────────────────────────────────────────────
attendanceReportCmd := &cobra.Command{
attendanceReportCmd := newGroupCommand(&cobra.Command{
Use: "report",
Short: "查询考勤报表和结果",
Long: `考勤 MCP 报表接口,仅对管理员开放
@@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
query-data 根据字段查询考勤数据
query-leave 查询用户假期数据`,
RunE: groupRunE,
}
})
// MCP tool: get_report_columns
reportColumnsCmd := &cobra.Command{
@@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── 假期 vacation ───────────────────────────────────────────────
attendanceVacationCmd := &cobra.Command{
attendanceVacationCmd := newGroupCommand(&cobra.Command{
Use: "vacation",
Short: "假期管理",
Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。
@@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
save-balance 更新员工假期余额
records 查询指定员工假期余额变更记录`,
RunE: groupRunE,
}
})
// ── 假期规则 types ─────────────────────────────────────────
@@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── schedule ──────────────────────────────────────────────
attendanceScheduleCmd := &cobra.Command{
attendanceScheduleCmd := newGroupCommand(&cobra.Command{
Use: "schedule",
Short: "排班管理",
Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。
@@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
import 导入排班记录到排班制考勤组
get 获取指定用户的排班记录`,
RunE: groupRunE,
}
})
// schedule import (generateTurnSchedule)
scheduleImportCmd := &cobra.Command{
@@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── checkin ──────────────────────────────────────────────
checkinCmd := &cobra.Command{
checkinCmd := newGroupCommand(&cobra.Command{
Use: "checkin",
Short: "签到管理",
Long: `签到记录的查询。
子命令:
records 查询指定员工的签到记录`,
RunE: groupRunE,
}
})
// MCP tool: queryUserRecordByStaffIds
checkinRecordsCmd := &cobra.Command{
+20 -39
View File
@@ -11,6 +11,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -18,10 +20,9 @@ import (
// dws calendar — 日历产品命令组
// ──────────────────────────────────────────────────────────
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that prints
// a warning-level "Did you mean" hint to stderr (instead of returning an Error)
// and exits 0. Scoped to calendar.go on purpose so the shared cmdutil.HintSubCmd
// used by other products keeps returning errors as before.
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that returns
// the shared typed validation error while preserving Calendar's reviewed
// replacement guidance.
//
// The `suggestion` argument should be the bare corrected command (no leading
// "use:" / "hint:" prefix); the helper wraps it with the standard "Did you
@@ -29,14 +30,6 @@ import (
func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command {
c := hintSubCmd(use, suggestion)
c.DisableFlagParsing = true
c.RunE = func(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "warning: command %q does not exist\n hint: %s\t %s\n more: %s \n",
cmd.Parent().CommandPath()+" "+use,
suggestion,
"[MUST] use --help to see command detail",
"'dws calendar --help' to see all available commands")
return nil
}
return c
}
@@ -56,6 +49,7 @@ func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command {
// child's RunE as usual; this fallback only fires when resolution stops at
// `group` with leftover args.
func installUnknownVerbFallback(group *cobra.Command) {
cmdutil.MarkGroup(group)
group.FParseErrWhitelist.UnknownFlags = true
group.Args = cobra.ArbitraryArgs
@@ -92,8 +86,7 @@ func installUnknownVerbFallback(group *cobra.Command) {
// Unknown flags whitelisted by pflag may leak into args. Pick the first
// non-flag token as the offending verb.
if bad := findUnknownVerb(cmd, args); bad != "" {
printUnknownSubcmdError(cmd, bad)
return nil
return groupRunE(cmd, []string{bad})
}
// No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently
// swallows bad flags, scan the original os.Args for flags unregistered
@@ -140,22 +133,10 @@ func findUnknownVerb(cmd *cobra.Command, args []string) string {
return ""
}
// printUnknownSubcmdError prints the standard "unknown subcommand" error to
// stderr with available commands and a did-you-mean hint.
// printUnknownSubcmdError renders the shared bounded diagnostic for Cobra's
// HelpFunc path, which cannot return the structured error to its caller.
func printUnknownSubcmdError(cmd *cobra.Command, bad string) {
var available []string
for _, c := range cmd.Commands() {
if !c.Hidden && c.Name() != "help" {
available = append(available, c.Name())
}
}
fmt.Fprintf(os.Stderr, "Error: unknown subcommand %q for %q\n", bad, cmd.CommandPath())
fmt.Fprintf(os.Stderr, " available: %s\n", strings.Join(available, ", "))
if s := cmd.SuggestionsFor(bad); len(s) > 0 {
fmt.Fprintf(os.Stderr, " hint: did you mean %q\n", cmd.CommandPath()+" "+s[0])
} else {
fmt.Fprintf(os.Stderr, " hint: %s --help\n", cmd.CommandPath())
}
_ = apperrors.PrintHuman(cmd.ErrOrStderr(), groupRunE(cmd, []string{bad}))
}
// stripCommandPrefix strips the first `depth` non-flag tokens from args.
@@ -224,7 +205,7 @@ func newCalendarCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "calendar",
Short: "日历日程 / 会议室 / 闲忙",
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
@@ -238,11 +219,11 @@ func newCalendarCommand() *cobra.Command {
dws calendar book [list|get|search|update] 日历本管理
dws calendar acl [list|add|delete] 日历访问权限管理`,
RunE: groupRunE,
}
})
// ── event: 日程 ─────────────────────────────────────────────
eventCmd := &cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE}
eventCmd := newGroupCommand(&cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE})
eventListCmd := &cobra.Command{
Use: "list",
@@ -816,13 +797,13 @@ func newCalendarCommand() *cobra.Command {
// ── attendee: 参会人 (曾用名: participant) ─────────────────
participantCmd := &cobra.Command{
participantCmd := newGroupCommand(&cobra.Command{
Use: "attendee",
Aliases: []string{"participant"},
Short: "日程参会人管理",
Long: "管理日程的参会人。alias:`participant`,仍作为别名保留,历史调用无需改动。",
RunE: groupRunE,
}
})
participantListCmd := &cobra.Command{
Use: "list",
@@ -1008,7 +989,7 @@ func newCalendarCommand() *cobra.Command {
// ── room: 会议室 ────────────────────────────────────────────
roomCmd := &cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE}
roomCmd := newGroupCommand(&cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE})
roomSearchCmd := &cobra.Command{
Use: "search",
@@ -1352,7 +1333,7 @@ func newCalendarCommand() *cobra.Command {
// ── busy: 闲忙 ──────────────────────────────────────────────
busyCmd := &cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE}
busyCmd := newGroupCommand(&cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE})
busySearchCmd := &cobra.Command{
Use: "search",
@@ -1444,7 +1425,7 @@ func newCalendarCommand() *cobra.Command {
// ── attachment: 附件 ────────────────────────────────────────
attachmentCmd := &cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE})
attachmentAddCmd := &cobra.Command{
Use: "add",
@@ -1529,7 +1510,7 @@ func newCalendarCommand() *cobra.Command {
// ── acl: 日历访问权限 ─────────────────────────────────────────
aclCmd := &cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE}
aclCmd := newGroupCommand(&cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE})
aclListCmd := &cobra.Command{
Use: "list",
@@ -1619,7 +1600,7 @@ func newCalendarCommand() *cobra.Command {
// ── book: 日历本 ────────────────────────────────────────────
bookCmd := &cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE}
bookCmd := newGroupCommand(&cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE})
bookListCmd := &cobra.Command{
Use: "list",
@@ -191,7 +191,9 @@ func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) {
hint := calendarInfoHintSubCmd("query", "use search")
group.AddCommand(hint)
_ = hint.RunE(hint, nil)
if err := hint.RunE(hint, nil); err == nil {
t.Fatal("calendar compatibility hint succeeded")
}
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
+17 -16
View File
@@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "chat",
Aliases: []string{"im"},
Short: "群聊 / 消息 / 机器人",
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
RunE: groupRunE,
}
})
chatChmodCmd := &cobra.Command{
Use: "chmod <scope>",
@@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command {
},
})
chatDataAuthCmd := &cobra.Command{
chatDataAuthCmd := newGroupCommand(&cobra.Command{
Use: "data-auth",
Short: "授予 chat 数据读取权限",
Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`,
RunE: groupRunE,
}
})
chatDataAuthCrossOrgCmd := &cobra.Command{
Use: "cross-org",
Short: "授予跨组织 chat 数据访问权限",
@@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command {
// ── group 子命令 ──────────────────────────────────────────
chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}
chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE})
chatGroupCreateCmd := &cobra.Command{
Use: "create",
@@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command {
return callMCPTool("get_group_members", toolArgs)
},
}
markGroup(chatGroupMembersCmd)
chatGroupMembersAddBotCmd := &cobra.Command{
Use: "add-bot",
@@ -2974,12 +2975,12 @@ func newChatCommand() *cobra.Command {
// ── message 子命令 ────────────────────────────────────────
chatMessageCmd := &cobra.Command{
chatMessageCmd := newGroupCommand(&cobra.Command{
Use: "message",
Short: "会话消息管理",
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
RunE: groupRunE,
}
})
chatMessageListCmd := &cobra.Command{
Use: "list",
@@ -4674,7 +4675,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── bot 子命令 ────────────────────────────────────────────
chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE}
chatBotCmd := newGroupCommand(&cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE})
chatBotSearchCmd := &cobra.Command{
Use: "search",
@@ -5210,12 +5211,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── file 子命令(会话文件上传,不暴露 spaceId)───────────────
chatFileCmd := &cobra.Command{
chatFileCmd := newGroupCommand(&cobra.Command{
Use: "file",
Short: "会话文件上传(已下线)",
Hidden: true,
RunE: groupRunE,
}
})
chatFileUploadCmd := &cobra.Command{
Use: "upload",
@@ -5250,7 +5251,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── category 子命令(会话分组,走 IM MCP)───────────────────
chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE}
chatCategoryCmd := newGroupCommand(&cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE})
chatCategoryListCmd := &cobra.Command{
Use: "list",
@@ -7580,7 +7581,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
// ── group-role 子命令(群身份管理)────────────────────────
chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE}
chatGroupRoleCmd := newGroupCommand(&cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE})
chatGroupRoleListCmd := &cobra.Command{
Use: "list",
@@ -9864,7 +9865,7 @@ status 可选值:
})
// ── group notice: 群公告管理 ────────────────────────────────
chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}
chatGroupNoticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE})
chatGroupNoticeCreateCmd := &cobra.Command{
Use: "create",
@@ -10385,7 +10386,7 @@ status 可选值:
"fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true,
"he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true,
}
chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}
chatTextCmd := newGroupCommand(&cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE})
chatTextTranslateCmd := &cobra.Command{
Use: "translate",
Short: "翻译文本内容",
@@ -10450,11 +10451,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
// ── chat group user-settings ──
chatGroupUserSettingsCmd := &cobra.Command{
chatGroupUserSettingsCmd := newGroupCommand(&cobra.Command{
Use: "user-settings",
Short: "批量查询或更新当前用户的群会话设置",
RunE: groupRunE,
}
})
chatGroupUserSettingsQueryCmd := &cobra.Command{
Use: "query",
Short: "批量查询当前用户的群会话设置",
+5 -1
View File
@@ -13,7 +13,10 @@
package chat
import "github.com/spf13/cobra"
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func newChatToolbarCommand() *cobra.Command {
toolbarCmd := &cobra.Command{
@@ -22,6 +25,7 @@ func newChatToolbarCommand() *cobra.Command {
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
RunE: groupRunE,
}
cmdutil.MarkGroup(toolbarCmd)
toolbarCmd.DisableAutoGenTag = true
toolbarCmd.AddCommand(
+7 -2
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err)
}
if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint)
}
}
}
+1
View File
@@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command {
},
}
media.AddCommand(newChatMediaUploadCommand())
markGroup(media)
return media
}
+2 -2
View File
@@ -13,12 +13,12 @@ import (
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
cmd := newGroupCommand(&cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
})
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
+3 -2
View File
@@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command {
直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`,
RunE: runUnavailable,
}
markGroup(root)
meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}
meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE})
meetingCreateCmd := &cobra.Command{
Use: "reserve",
@@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command {
root.AddCommand(meetingCmd)
// member 子命令组 — 成员管理
memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}
memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE})
memberInviteCmd := &cobra.Command{
Use: "invite",
+17 -17
View File
@@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "contact",
Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系",
Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。
@@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command {
- contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等)
- contact user dismission search: 离职员工列表查询`,
RunE: groupRunE,
}
})
userCmd := &cobra.Command{
userCmd := newGroupCommand(&cobra.Command{
Use: "user",
Short: "人员管理",
Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。
@@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
RunE: groupRunE,
}
})
contactUserGetSelfCmd := &cobra.Command{
Use: "get-self",
@@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command {
},
})
relationCmd := &cobra.Command{Use: "relation",
relationCmd := newGroupCommand(&cobra.Command{Use: "relation",
Short: "人员关系查询",
Long: `查询钉钉人员关系:特别关注人。`,
RunE: groupRunE}
RunE: groupRunE})
contactRelationListMyFollowingsCmd := &cobra.Command{
Use: "list-my-followings",
@@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command {
// ── label 角色 ──────────────────────────────────────────────────
contactLabelCmd := &cobra.Command{
contactLabelCmd := newGroupCommand(&cobra.Command{
Use: "label",
Aliases: []string{"role"},
Short: "角色查询",
@@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command {
2. 从返回结果中匹配目标角色名称及 labelId
3. contact label list-members --id <labelId> → 获取该角色下的成员`,
RunE: groupRunE,
}
})
runContactLabelList := func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
@@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command {
contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd)
contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}
contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE})
contactDeptSearchCmd := &cobra.Command{
Use: "search",
@@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command {
})
// ── user profile 用户档案(花名册) ────────────────────────────────────
contactUserProfileCmd := &cobra.Command{
contactUserProfileCmd := newGroupCommand(&cobra.Command{
Use: "profile",
Short: "用户档案(花名册)",
Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。
@@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command {
- contact user get: 组织管理信息(部门、主管、管理员权限)
- contact user profile get: 个人档案信息(学历、家庭、银行卡等)`,
RunE: groupRunE,
}
})
contactUserProfileFieldsCmd := &cobra.Command{
Use: "fields",
@@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。
contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd)
// ── user dismission 离职员工 ───────────────────────────────────────────
contactUserDismissionCmd := &cobra.Command{
contactUserDismissionCmd := newGroupCommand(&cobra.Command{
Use: "dismission",
Short: "离职员工查询",
Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`,
RunE: groupRunE,
}
})
contactUserDismissionSearchCmd := &cobra.Command{
Use: "search",
@@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。
// ── org 企业管理 ──────────────────────────────────────────────────
contactOrgCmd := &cobra.Command{
contactOrgCmd := newGroupCommand(&cobra.Command{
Use: "org",
Short: "企业管理",
Long: `企业管理:创建企业。
@@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。
- 创建企业专属账号 → contact account create
- 邀请员工加入企业 → contact user invite`,
RunE: groupRunE,
}
})
contactOrgCreateCmd := &cobra.Command{
Use: "create",
@@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。
// ── account 企业账号管理 ──────────────────────────────────────────
contactAccountCmd := &cobra.Command{
contactAccountCmd := newGroupCommand(&cobra.Command{
Use: "account",
Short: "企业账号管理",
Long: "企业账号管理:创建或更新企业专属账号。",
RunE: groupRunE,
}
})
contactAccountCreateCmd := &cobra.Command{
Use: "create",
@@ -1,9 +1,11 @@
package helpers
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T
if err := hint.RunE(hint, []string{"--help"}); err != nil {
t.Fatalf("hint help: %v", err)
}
if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") {
t.Fatalf("hint guidance err=%v", err)
err := hint.RunE(hint, []string{"unexpected"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") {
t.Fatalf("hint guidance err=%#v", structured)
}
if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") {
+1
View File
@@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
)
markGroup(cmd)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
+3 -3
View File
@@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "devdoc",
Short: "开放平台文档搜索",
Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`,
RunE: groupRunE,
}
})
articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}
articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE})
articleCmd.AddCommand(newDevdocArticleSearchCommand())
root.AddCommand(articleCmd)
root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>"))
+3 -3
View File
@@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "ding",
Short: "DING 消息 / 发送 / 撤回",
Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`,
RunE: groupRunE,
}
})
dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}
dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE})
dingMessageSendCmd := &cobra.Command{
Use: "send",
+16 -15
View File
@@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "doc",
Short: "钉钉文档管理",
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
@@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command {
文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`,
RunE: groupRunE,
}
})
searchCmd := &cobra.Command{
Use: "search",
@@ -1826,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}
fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE})
fileCreateCmd := &cobra.Command{
Use: "create",
@@ -1904,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE})
folderCreateCmd := &cobra.Command{
Use: "create",
@@ -2063,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
blockCmd := &cobra.Command{
blockCmd := newGroupCommand(&cobra.Command{
Use: "block",
Short: "块级编辑",
Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`,
RunE: groupRunE,
}
})
blockListCmd := &cobra.Command{
Use: "list",
@@ -2844,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
_ = renameCmd.Flags().MarkHidden("title")
// ── media (文档媒体/附件) ────────────────────────────────
mediaCmd := &cobra.Command{
mediaCmd := newGroupCommand(&cobra.Command{
Use: "media",
Short: "文档媒体 / 附件管理",
Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`,
RunE: groupRunE,
}
})
mediaDownloadCmd := &cobra.Command{
Use: "download",
@@ -3036,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "文档评论 / 评论管理",
Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`,
RunE: groupRunE,
}
})
commentListCmd := &cobra.Command{
Use: "list",
@@ -3545,13 +3545,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
permissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档协作权限管理",
Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`,
RunE: groupRunE,
}
})
permissionAddCmd := &cobra.Command{
Use: "add",
@@ -4260,6 +4260,7 @@ CLI 内部自动完成全部流程:
_ = exportCmd.Flags().MarkHidden("file-id")
exportCmd.AddCommand(exportGetCmd)
markGroup(exportCmd)
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
importCmd := &cobra.Command{
@@ -4358,12 +4359,12 @@ CLI 内部自动完成全部流程:
importCmd.AddCommand(importGetCmd)
// ── doc version 子命令组 ──
versionCmd := &cobra.Command{
versionCmd := newGroupCommand(&cobra.Command{
Use: "version",
Short: "文档历史版本管理",
Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
})
versionSaveCmd := &cobra.Command{
Use: "save",
@@ -4549,7 +4550,7 @@ CLI 内部自动完成全部流程:
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
// ── template 子命令组 ──────────────────────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE})
templateListCmd := &cobra.Command{
Use: "list",
+6 -6
View File
@@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
styleCmd := newGroupCommand(&cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
@@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command {
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
})
coverCmd := &cobra.Command{
coverCmd := newGroupCommand(&cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
})
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
@@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command {
},
})
backgroundCmd := &cobra.Command{
backgroundCmd := newGroupCommand(&cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
})
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
+2 -2
View File
@@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
})
insertCmd := &cobra.Command{
Use: "insert",
+11 -11
View File
@@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command {
},
},
})
driveCmd := &cobra.Command{
driveCmd := newGroupCommand(&cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
RunE: groupRunE,
}
})
driveListCmd := &cobra.Command{
Use: "list",
@@ -2030,14 +2030,14 @@ func newDriveCommand() *cobra.Command {
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
// ── drive permission (文档节点权限管理) ──
drivePermissionCmd := &cobra.Command{
drivePermissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档节点权限管理",
Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。
注意: 仅适用于文档空间节点,不适用于钉盘文件。`,
RunE: groupRunE,
}
})
drivePermAddCmd := &cobra.Command{
Use: "add",
@@ -2806,12 +2806,12 @@ func newDriveCommand() *cobra.Command {
_ = driveRenameCmd.Flags().MarkHidden("title")
// ── drive recycle 子命令组 ──
recycleCmd := &cobra.Command{
recycleCmd := newGroupCommand(&cobra.Command{
Use: "recycle",
Short: "钉盘回收站管理",
Long: `管理钉盘回收站:查看回收站列表、还原回收项。`,
RunE: groupRunE,
}
})
recycleListCmd := &cobra.Command{
Use: "list",
@@ -2921,7 +2921,7 @@ func newDriveCommand() *cobra.Command {
// ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)──
// folder create → dws wiki node create --type folder
driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}
driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE})
driveFolderCreateCmd := &cobra.Command{
Use: "create",
Short: "创建文件夹(deprecated)",
@@ -2951,12 +2951,12 @@ func newDriveCommand() *cobra.Command {
driveFolderCmd.AddCommand(driveFolderCreateCmd)
// ── drive publish (文件互联网公开发布管理) ──
drivePublishCmd := &cobra.Command{
drivePublishCmd := newGroupCommand(&cobra.Command{
Use: "publish",
Short: "文件互联网公开发布管理",
Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`,
RunE: groupRunE,
}
})
drivePublishSetCmd := &cobra.Command{
Use: "set",
@@ -3251,11 +3251,11 @@ func newDriveCommand() *cobra.Command {
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := &cobra.Command{
driveStarCmd := newGroupCommand(&cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
}
})
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
+2 -2
View File
@@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag {
// doc comment, sheet comment, and drive comment. The public command belongs to
// Drive, while the implementation routes to the shared doc-comment MCP server.
func newDriveFileCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
})
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
@@ -0,0 +1,101 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import (
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strings"
"testing"
)
func TestCrossPlatformCoverageGroupRunEDeclarationsUseExplicitGroupConstructor(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read helpers directory: %v", err)
}
fset := token.NewFileSet()
var violations []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, parseErr := parser.ParseFile(fset, name, nil, parser.ParseComments)
if parseErr != nil {
t.Fatalf("parse %s: %v", name, parseErr)
}
// 生成文件必须通过其生成器维护,不能由机械迁移直接改写。
if ast.IsGenerated(file) {
continue
}
wrapped := explicitGroupCommandLiterals(file)
ast.Inspect(file, func(node ast.Node) bool {
literal, ok := node.(*ast.CompositeLit)
if !ok || !declaresGroupRunE(literal) {
return true
}
if _, ok := wrapped[literal]; !ok {
position := fset.Position(literal.Pos())
violations = append(violations, position.String())
}
return true
})
}
if len(violations) == 0 {
return
}
sort.Strings(violations)
shown := violations
if len(shown) > 10 {
shown = shown[:10]
}
t.Fatalf("%d groupRunE declarations bypass newGroupCommand; first %d:\n%s",
len(violations), len(shown), strings.Join(shown, "\n"))
}
func explicitGroupCommandLiterals(file *ast.File) map[*ast.CompositeLit]struct{} {
wrapped := make(map[*ast.CompositeLit]struct{})
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) != 1 {
return true
}
callee, ok := call.Fun.(*ast.Ident)
if !ok || callee.Name != "newGroupCommand" {
return true
}
argument, ok := call.Args[0].(*ast.UnaryExpr)
if !ok || argument.Op != token.AND {
return true
}
literal, ok := argument.X.(*ast.CompositeLit)
if ok {
wrapped[literal] = struct{}{}
}
return true
})
return wrapped
}
func declaresGroupRunE(literal *ast.CompositeLit) bool {
for _, element := range literal.Elts {
field, ok := element.(*ast.KeyValueExpr)
if !ok {
continue
}
key, keyOK := field.Key.(*ast.Ident)
value, valueOK := field.Value.(*ast.Ident)
if keyOK && valueOK && key.Name == "RunE" && value.Name == "groupRunE" {
return true
}
}
return false
}
+16 -6
View File
@@ -16,17 +16,19 @@ import (
"github.com/spf13/pflag"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"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.
// Re-export shared command helpers as package-level aliases so existing product
// files continue to compile with their current (unexported) call sites. This
// avoids a mass-rename while keeping command-error classification in pipeline
// and the remaining reusable utilities in cmdutil.
var (
groupRunE = cmdutil.GroupRunE
hintSubCmd = cmdutil.HintSubCmd
groupRunE = pipeline.GroupRunE
hintSubCmd = pipeline.HintSubCmd
markGroup = cmdutil.MarkGroup
mustGetFlag = cmdutil.MustGetFlag
flagOrFallback = cmdutil.FlagOrFallback
mustFlagOrFallback = cmdutil.MustFlagOrFallback
@@ -38,6 +40,14 @@ var (
helperAfter = time.After
)
// newGroupCommand declares a navigation-only command container. The explicit
// identity lets pre-parse command recovery take precedence over child flags
// without inferring group semantics from the shape of the Cobra tree.
func newGroupCommand(command *cobra.Command) *cobra.Command {
markGroup(command)
return command
}
// Deps holds shared dependencies injected from the host application.
type Deps struct {
Caller edition.ToolCaller
+5 -5
View File
@@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "hrbrain",
Short: "组织大脑:人才池、员工档案与人才搜索",
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
@@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command {
dws hrbrain search employees-structured 使用高级条件搜索人员
dws hrbrain search fields 获取高级搜索字段列表`,
RunE: groupRunE,
}
})
// ── talent-pool: 人才池管理 ────────────────────────────────
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE})
talentPoolListCmd := &cobra.Command{
Use: "list",
@@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command {
// ── profile: 员工档案管理 ──────────────────────────────────
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE})
profileMetadataCmd := &cobra.Command{
Use: "metadata",
@@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command {
// ── search: 人才搜索 ─────────────────────────────────────
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE})
employeeSearchCmd := &cobra.Command{
Use: "employees",
+7 -1
View File
@@ -21,6 +21,7 @@ import (
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -72,7 +73,12 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command
out := make([]*cobra.Command, 0, len(factories))
for _, factory := range factories {
handler := factory()
out = append(out, handler.Command(runner))
command := handler.Command(runner)
// A public helper Factory owns one product root. Product roots are
// positional-free command containers even when they also provide a
// flag-driven default action (for example aisearch).
cmdutil.MarkGroup(command)
out = append(out, command)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Use < out[j].Use
+3 -3
View File
@@ -20,14 +20,14 @@ func newLiveCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "live",
Short: "直播列表 / 信息",
Long: `查看钉钉直播:列出我的直播记录。`,
RunE: groupRunE,
}
})
streamCmd := &cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE}
streamCmd := newGroupCommand(&cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE})
streamListCmd := &cobra.Command{
Use: "list",
+19 -19
View File
@@ -113,14 +113,14 @@ func newMailCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "mail",
Short: "邮箱 / 邮件收发",
Long: `管理钉钉企业邮箱:查询邮箱地址、搜索邮件、查看邮件、发送邮件、获取会话(thread)、列举文件夹、列举标签。`,
RunE: groupRunE,
}
})
mailboxCmd := &cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE}
mailboxCmd := newGroupCommand(&cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE})
mailboxListCmd := &cobra.Command{
Use: "list",
@@ -263,7 +263,7 @@ func newMailCommand() *cobra.Command {
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE})
messageSearchCmd := &cobra.Command{
Use: "search",
@@ -588,7 +588,7 @@ func newMailCommand() *cobra.Command {
},
})
folderCmd := &cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE}
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE})
folderListCmd := &cobra.Command{
Use: "list",
@@ -842,7 +842,7 @@ func newMailCommand() *cobra.Command {
folderCmd.AddCommand(folderListCmd, folderCreateCmd, folderDeleteCmd, folderUpdateCmd)
tagCmd := &cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE}
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE})
tagListCmd := &cobra.Command{
Use: "list",
@@ -995,7 +995,7 @@ func newMailCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagDeleteCmd, tagUpdateCmd)
threadCmd := &cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE}
threadCmd := newGroupCommand(&cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE})
threadListCmd := &cobra.Command{
Use: "list",
@@ -1975,7 +1975,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
},
})
attachmentCmd := &cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE})
attachmentListCmd := &cobra.Command{
Use: "list",
@@ -2443,7 +2443,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
sentMessageCmd := newGroupCommand(&cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE})
sentMessageRecallCmd := &cobra.Command{
Use: "recall",
@@ -2600,10 +2600,10 @@ internetMessageId 来源:message send / draft send / message reply / message r
_ = draftSendCmd.Flags().MarkHidden("sender")
draftSendCmd.Flags().String("id", "", "草稿邮件 ID (必填)")
draftCmd := &cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE}
draftCmd := newGroupCommand(&cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE})
draftCmd.AddCommand(draftCreateCmd, draftUpdateCmd, draftSendCmd)
userCmd := &cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE})
userSearchCmd := &cobra.Command{
Use: "search",
@@ -2707,7 +2707,7 @@ user 对象字段:
// ── template 子命令组 ──────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE})
templateCreateCmd := &cobra.Command{
Use: "create",
@@ -3057,7 +3057,7 @@ user 对象字段:
// ── contact 子命令组 ──────────────────────────────────
contactCmd := &cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE}
contactCmd := newGroupCommand(&cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE})
contactCreateCmd := &cobra.Command{
Use: "create",
@@ -3315,7 +3315,7 @@ user 对象字段:
contactCmd.AddCommand(contactCreateCmd, contactListCmd, contactUpdateCmd, contactBatchDeleteCmd)
// ── auto-reply 自动回复 ──────────────────────────────
autoReplyCmd := &cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE}
autoReplyCmd := newGroupCommand(&cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE})
autoReplyGetCmd := &cobra.Command{
Use: "get",
@@ -3393,7 +3393,7 @@ user 对象字段:
autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd)
// ── rule 收信规则 ────────────────────────────────────
ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}
ruleCmd := newGroupCommand(&cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE})
ruleListCmd := &cobra.Command{
Use: "list",
@@ -3578,7 +3578,7 @@ object 与 operation 合法组合:
ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd)
// ── allow-list 个人收信白名单 ────────────────────────────────
allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}
allowListCmd := newGroupCommand(&cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE})
allowListListCmd := &cobra.Command{
Use: "list",
@@ -3646,7 +3646,7 @@ object 与 operation 合法组合:
allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd)
// ── block-list 个人收信黑名单 ────────────────────────────────
blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}
blockListCmd := newGroupCommand(&cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE})
blockListListCmd := &cobra.Command{
Use: "list",
@@ -3713,7 +3713,7 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarCmd := newGroupCommand(&cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE})
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
@@ -3762,7 +3762,7 @@ object 与 operation 合法组合:
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventCmd := newGroupCommand(&cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE})
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
+2 -2
View File
@@ -46,12 +46,12 @@ func newMarkdownCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
})
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
+14 -14
View File
@@ -28,7 +28,7 @@ func newMinutesCommand() *cobra.Command {
},
},
})
minutesListCmd := &cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE}
minutesListCmd := newGroupCommand(&cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE})
minutesListMineCmd := &cobra.Command{
Use: "mine",
@@ -173,7 +173,7 @@ func newMinutesCommand() *cobra.Command {
},
})
minutesGetCmd := &cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE}
minutesGetCmd := newGroupCommand(&cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE})
minutesGetInfoCmd := &cobra.Command{
Use: "info",
@@ -544,7 +544,7 @@ func newMinutesCommand() *cobra.Command {
},
})
minutesUpdateCmd := &cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE}
minutesUpdateCmd := newGroupCommand(&cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE})
minutesUpdateTitleCmd := &cobra.Command{
Use: "title",
@@ -659,7 +659,7 @@ func newMinutesCommand() *cobra.Command {
// 单个工具通过 cmd 参数覆盖 create/pause/resume/end 四种指令。
const listeningNoteCmdTool = "执行听记指令-发起AI听记录音"
minutesRecordCmd := &cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE}
minutesRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE})
minutesRecordStartCmd := &cobra.Command{
Use: "start",
@@ -942,7 +942,7 @@ func newMinutesCommand() *cobra.Command {
minutesUpdateCmd.AddCommand(minutesUpdateTitleCmd, minutesUpdateSummaryCmd)
// ── mind-graph 子组 ─────────────────────────────────────────
mindGraphCmd := &cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE}
mindGraphCmd := newGroupCommand(&cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE})
mindGraphCreateCmd := &cobra.Command{
Use: "create",
@@ -1059,7 +1059,7 @@ func newMinutesCommand() *cobra.Command {
mindGraphCmd.AddCommand(mindGraphCreateCmd, mindGraphStatusCmd)
// ── speaker 子组 ────────────────────────────────────────────
speakerCmd := &cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE}
speakerCmd := newGroupCommand(&cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE})
speakerReplaceCmd := &cobra.Command{
Use: "replace",
@@ -1127,7 +1127,7 @@ func newMinutesCommand() *cobra.Command {
// 对应 MCP 工具 create_speaker_summary / get_speaker_summary
// 批量按听记维度汇总每位发言人的段落总结
speakerSummaryCmd := &cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE}
speakerSummaryCmd := newGroupCommand(&cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE})
speakerSummaryCreateCmd := &cobra.Command{
Use: "create",
@@ -1243,7 +1243,7 @@ func newMinutesCommand() *cobra.Command {
speakerCmd.AddCommand(speakerReplaceCmd, speakerSummaryCmd)
// ── hot-word 子组 ───────────────────────────────────────────
hotWordCmd := &cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE}
hotWordCmd := newGroupCommand(&cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE})
hotWordAddCmd := &cobra.Command{
Use: "add",
@@ -1462,7 +1462,7 @@ func newMinutesCommand() *cobra.Command {
// 完整流程:create → HTTP PUT → complete,或 create → cancel 取消。
// 注意:upload 子组的所有命令均使用 callMCPToolUnescaped 输出 JSON,
// 避免 presignedUrl 中的 & 被 Go 标准库转义为 \u0026。
uploadCmd := &cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE}
uploadCmd := newGroupCommand(&cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE})
// upload create — 对应 MCP 工具 create_upload_session
// 必填参数:fileName(--file-name), fileSize(--file-size)
@@ -1681,7 +1681,7 @@ func newMinutesCommand() *cobra.Command {
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
permissionCmd := newGroupCommand(&cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE})
// permission add — 对应 MCP 工具 add_member_permission
// 批量给多个听记增加成员,并设置成员的权限。
@@ -1951,7 +1951,7 @@ func newMinutesCommand() *cobra.Command {
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
// 对应 MCP 工具 query_user_tag_list / query_minutes_by_tag_id。
// 标签/分组由用户在听记页面手动创建,此处仅提供查询能力。
tagCmd := &cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE}
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE})
// tag list — 对应 MCP 工具 query_user_tag_list
// 无需传入参数,系统自动识别当前用户身份。
@@ -2075,7 +2075,7 @@ func newMinutesCommand() *cobra.Command {
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoCmd := newGroupCommand(&cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE})
audioMemoListCmd := &cobra.Command{
Use: "list",
@@ -2179,12 +2179,12 @@ func newMinutesCommand() *cobra.Command {
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
minutesCmd := newGroupCommand(&cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
})
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
+5 -5
View File
@@ -369,11 +369,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
attachmentCmd := newGroupCommand(&cobra.Command{
Use: "attachment",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
})
downloadURLCmd := NewLeafCommand(LeafSpec{
Use: "download-url",
@@ -724,14 +724,14 @@ func newOaCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "oa",
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
RunE: groupRunE,
}
})
approvalCmd := &cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE}
approvalCmd := newGroupCommand(&cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE})
approvalListPendingCmd := &cobra.Command{
Use: "list-pending",
+4 -4
View File
@@ -61,17 +61,17 @@ func newRecruitCommand() *cobra.Command {
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "recruit",
Short: "钉钉招聘",
Long: "查询和创建钉钉招聘中的职位信息。",
RunE: groupRunE,
}
job := &cobra.Command{
})
job := newGroupCommand(&cobra.Command{
Use: "job",
Short: "招聘职位管理",
RunE: groupRunE,
}
})
job.AddCommand(
newRecruitJobListCommand(),
newRecruitJobGetCommand(),
+6 -5
View File
@@ -64,7 +64,7 @@ func newReportCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "report",
Aliases: []string{"log"},
Short: "钉钉日志(OA 周报应用 / 日志模版填报)",
@@ -84,10 +84,10 @@ func newReportCommand() *cobra.Command {
别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`,
RunE: groupRunE,
}
})
// === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)===
templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE})
templateListCmd := &cobra.Command{
Use: "list",
@@ -179,7 +179,7 @@ func newReportCommand() *cobra.Command {
templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd)
// === entry subtree(单条日报操作 — get / stats / submit)===
entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}
entryCmd := newGroupCommand(&cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE})
entryGetCmd := &cobra.Command{
Use: "get",
@@ -386,9 +386,10 @@ func newReportCommand() *cobra.Command {
addReportListFlags(inboxListCmd)
inboxCmd.AddCommand(inboxListCmd)
markGroup(inboxCmd)
// === outbox subtree(我发出的日报)===
outboxCmd := &cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}
outboxCmd := newGroupCommand(&cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE})
outboxListCmd := &cobra.Command{
Use: "list",
+21 -12
View File
@@ -2,9 +2,12 @@ package helpers
import (
"fmt"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -328,8 +331,8 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi
// 未知 flag 连同其值一起被静默消化;
// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理;
// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子
// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra
// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。
// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回共享的
// 有界同级编辑距离建议;最终返回 error 以保证 exit!=0。
//
// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段
// 精确匹配到的合法叶子命令。
@@ -347,20 +350,26 @@ func attachUnknownSubcommandGuard(cmd *cobra.Command) {
return c.Help()
}
name := args[0]
var buf strings.Builder
fmt.Fprintf(&buf, "unknown command %q for %q", name, c.CommandPath())
suggestions := deepSuggestSubcommand(c, name)
if len(suggestions) == 0 {
suggestions = c.SuggestionsFor(name)
}
if len(suggestions) > 0 {
buf.WriteString("\n\nDid you mean this?")
for _, s := range suggestions {
fmt.Fprintf(&buf, "\n\t%s %s", c.CommandPath(), s)
suggestions = cmdutil.SuggestSubcommands(c, name)
} else {
sort.Strings(suggestions)
if len(suggestions) > cmdutil.MaxCommandSuggestions {
suggestions = suggestions[:cmdutil.MaxCommandSuggestions]
}
}
fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath())
return fmt.Errorf("%s", buf.String())
action := fmt.Sprintf("Run '%s --help' for the full list", c.CommandPath())
return apperrors.NewValidation(
fmt.Sprintf("unknown subcommand %q for %q", name, c.CommandPath()),
apperrors.WithReason("unknown_subcommand"),
apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(c, suggestions, action)),
apperrors.WithActions(action),
apperrors.WithDetails(map[string]any{
"input": name,
"suggestions": suggestions,
}),
)
}
}
+2 -2
View File
@@ -7,12 +7,12 @@ import (
)
func newSheetCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "表格评论 / 单元格评论管理",
Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`,
RunE: groupRunE,
}
})
commentListCmd := &cobra.Command{
Use: "list",
+2 -2
View File
@@ -134,11 +134,11 @@ func readPivotProperties(raw string, requireValues bool) (map[string]any, error)
}
func newPivotTableCmd() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "pivot-table",
Short: "透视表管理",
RunE: groupRunE,
}
})
listCmd := &cobra.Command{
Use: "list",
+2 -2
View File
@@ -9,11 +9,11 @@ import (
// newSheetTemplateCmd builds the `dws sheet template` command group.
func newSheetTemplateCmd() *cobra.Command {
templateCmd := &cobra.Command{
templateCmd := newGroupCommand(&cobra.Command{
Use: "template",
Short: "表格模板管理",
RunE: groupRunE,
}
})
templateListCmd := &cobra.Command{
Use: "list",
+2 -2
View File
@@ -8,12 +8,12 @@ import (
)
func newSheetVersionCmd() *cobra.Command {
versionCmd := &cobra.Command{
versionCmd := newGroupCommand(&cobra.Command{
Use: "version",
Short: "表格历史版本管理",
Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
})
versionSaveCmd := &cobra.Command{
Use: "save",
@@ -8,6 +8,7 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -21,8 +22,10 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T
group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
root.AddCommand(group)
attachUnknownSubcommandGuard(root)
if err := root.RunE(root, []string{"read"}); err == nil || !strings.Contains(err.Error(), "range read") {
t.Fatalf("deep suggestion err=%v", err)
err := root.RunE(root, []string{"read"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "range read") {
t.Fatalf("deep suggestion err=%#v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
+5 -5
View File
@@ -69,14 +69,14 @@ func newTodoCommand() *cobra.Command {
},
},
})
todoCmd := &cobra.Command{
todoCmd := newGroupCommand(&cobra.Command{
Use: "todo",
Short: "待办任务管理",
Long: `管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。`,
RunE: groupRunE,
}
})
todoTaskCmd := &cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE}
todoTaskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE})
todoTaskCreateCmd := &cobra.Command{
Use: "create",
@@ -1303,7 +1303,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
// dws todo comment — 待办评论
// 对应 MCP:add_todo_comment / list_todo_comment / delete_todo_comment
// ──────────────────────────────────────────────────────────
todoCommentCmd := &cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE}
todoCommentCmd := newGroupCommand(&cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE})
todoCommentAddCmd := &cobra.Command{
Use: "add",
@@ -1474,7 +1474,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
// dws todo tag — 待办标签
// 对应 MCP:tag_todo / delete_todo_tag / update_todo_tag / list_todo_tags / create_todo_tag
// ──────────────────────────────────────────────────────────
todoTagCmd := &cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE}
todoTagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE})
todoTagAddCmd := &cobra.Command{
Use: "add",
+2 -2
View File
@@ -45,7 +45,7 @@ func newWhiteboardCommand() *cobra.Command {
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
@@ -53,7 +53,7 @@ func newWhiteboardCommand() *cobra.Command {
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
})
queryCmd := &cobra.Command{
Use: "query",
+11 -11
View File
@@ -160,14 +160,14 @@ func newWikiCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "wiki",
Short: "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)、动态查询(知识库活动动态)。`,
RunE: groupRunE,
}
})
spaceCmd := &cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE}
spaceCmd := newGroupCommand(&cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE})
spaceCreateCmd := &cobra.Command{
Use: "create",
@@ -519,12 +519,12 @@ func newWikiCommand() *cobra.Command {
spaceCmd.AddCommand(spaceCreateCmd, spaceGetCmd, spaceListCmd, spaceSearchCmd, spaceDeleteCmd)
// ── member (知识库成员管理) ───────────────────────────────
memberCmd := &cobra.Command{
memberCmd := newGroupCommand(&cobra.Command{
Use: "member",
Short: "知识库成员管理",
Long: `管理钉钉知识库的成员:添加成员、更新成员权限、查询成员列表、移除成员。`,
RunE: groupRunE,
}
})
memberAddCmd := &cobra.Command{
Use: "add",
@@ -954,12 +954,12 @@ ORG 类型授权不会出现在查询结果中。`,
// ── node (知识库节点管理) ─────────────────────────────────
// 对齐飞书 cli-lark wiki node 设计:内建 list/create/copy/move/delete,
// 一跳直达 doc MCP server,不再经过 proxy chain。
nodeCmd := &cobra.Command{
nodeCmd := newGroupCommand(&cobra.Command{
Use: "node",
Short: "知识库节点管理",
Long: `管理钉钉知识库中的节点(文档/文件夹/表格等):列出、创建、复制、移动、删除。`,
RunE: groupRunE,
}
})
nodeListCmd := &cobra.Command{
Use: "list",
@@ -1415,12 +1415,12 @@ ORG 类型授权不会出现在查询结果中。`,
root.AddCommand(nodeCmd)
// ── feed (知识库动态查询) ─────────────────────────────────
feedCmd := &cobra.Command{
feedCmd := newGroupCommand(&cobra.Command{
Use: "feed",
Short: "知识库动态查询",
Long: `查询知识库的动态:谁在什么时间更新/上传/评论了哪些文档。`,
RunE: groupRunE,
}
})
feedListCmd := &cobra.Command{
Use: "list",
@@ -1536,14 +1536,14 @@ ORG 类型授权不会出现在查询结果中。`,
)
// ── [PROXY] wiki file/doc * → doc * (兼容旧路径) ──
fileGroup := &cobra.Command{Use: "file", Hidden: true, RunE: groupRunE}
fileGroup := newGroupCommand(&cobra.Command{Use: "file", Hidden: true, RunE: groupRunE})
fileGroup.AddCommand(
proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki file list → doc list
proxySubCmd("search", "doc", "search", map[string]string{"workspace": "workspace-ids"}), // [PROXY] wiki file search → doc search
)
root.AddCommand(fileGroup)
docGroup := &cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE}
docGroup := newGroupCommand(&cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE})
docGroup.AddCommand(
proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki doc list → doc list
proxySubCmd("read", "doc", "read", nil), // [PROXY] wiki doc read → doc read
+3 -1
View File
@@ -20,6 +20,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -87,8 +88,9 @@ DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
它不参与 claw-type 派生,也不参与 host-owned PAT 判定。
DWS_CHANNEL 只用于上游 channelCode。`,
RunE: cmdutil.GroupRunE,
RunE: pipeline.GroupRunE,
}
cmdutil.MarkGroup(patCmd)
patCmd.AddCommand(newChmodCommand(c))
patCmd.AddCommand(newBrowserPolicyCommand())
+56 -65
View File
@@ -5,7 +5,6 @@ package pipeline
import (
"fmt"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -13,14 +12,13 @@ import (
"github.com/spf13/cobra"
)
const maxCommandSuggestions = 3
// validateUnresolvedCommand gives command identity precedence over flag
// parsing. Cobra otherwise falls back to the nearest parent when a child is
// unknown, so a later flag can incorrectly turn an invented command into an
// "unknown flag" error on that parent.
//
// Only two unambiguous command containers are handled here:
// Only explicit, unambiguous command containers are handled here:
// - the root command, which has no positional contract;
// - a top-level service followed by an explicit +shortcut token; and
// - a command explicitly annotated as a group container.
//
@@ -34,11 +32,17 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
return nil
}
// Cobra registers its built-in `help [command]` lazily during ExecuteC,
// after this pre-parse traversal runs. Preserve that reserved root entry so
// `dws help auth` reaches Cobra instead of being classified as a typo.
if target == target.Root() && candidate == "help" {
return nil
}
if isExplicitShortcutCandidate(target, candidate) {
return unknownShortcutError(target, candidate)
}
if cmdutil.IsGroup(target) {
if target == target.Root() || cmdutil.IsGroup(target) {
return unknownSubcommandError(target, candidate)
}
return nil
@@ -61,83 +65,70 @@ func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
}
func unknownShortcutError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
helpAction := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath())
listAction := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
suggestions := cmdutil.SuggestSubcommands(parent, candidate)
return apperrors.NewValidation(
fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()),
apperrors.WithReason("unknown_shortcut"),
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
apperrors.WithActions(action),
apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, helpAction)),
apperrors.WithActions(helpAction, listAction),
apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)),
)
}
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
action := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath())
suggestions := cmdutil.SuggestSubcommands(parent, candidate)
return apperrors.NewValidation(
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
apperrors.WithReason("unknown_subcommand"),
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, action)),
apperrors.WithActions(action),
apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)),
)
}
func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string {
suggestions := commandSuggestions(parent, candidate)
if len(suggestions) > maxCommandSuggestions {
suggestions = suggestions[:maxCommandSuggestions]
}
paths := make([]string, 0, len(suggestions))
for _, suggestion := range suggestions {
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
}
switch len(paths) {
case 0:
return fallback
case 1:
return "Did you mean " + paths[0] + "?"
default:
return "Did you mean one of: " + strings.Join(paths, ", ") + "?"
func commandSuggestionDetails(candidate string, suggestions []string) map[string]any {
return map[string]any{
"input": candidate,
"suggestions": suggestions,
}
}
// commandSuggestions mirrors Cobra's suggestion rules without mutating
// SuggestionsMinimumDistance. Cobra only installs its default distance inside
// the final error renderer; this guard runs earlier and must remain safe for
// command trees shared by concurrent tests and callers.
func commandSuggestions(parent *cobra.Command, candidate string) []string {
if parent == nil {
return nil
// HintSubCmd creates a hidden compatibility command for a known wrong path.
// It preserves cmdutil's hint-only identity while returning the same typed
// validation contract as fuzzy unknown-subcommand recovery.
func HintSubCmd(use, authoredHint string) *cobra.Command {
command := cmdutil.HintSubCmd(use, authoredHint)
command.RunE = func(cmd *cobra.Command, _ []string) error {
parent := cmd.Parent()
if parent == nil {
parent = cmd.Root()
}
parentPath := parent.CommandPath()
action := fmt.Sprintf("Run '%s --help' for the full list", parentPath)
hint := strings.TrimSpace(authoredHint)
if hint == "" {
hint = action
} else {
hint += " (" + action + ")"
}
return apperrors.NewValidation(
fmt.Sprintf("unknown subcommand %q for %q", cmd.Name(), parentPath),
apperrors.WithReason("unknown_subcommand"),
apperrors.WithHint(hint),
apperrors.WithActions(action),
apperrors.WithDetails(commandSuggestionDetails(cmd.Name(), []string{})),
)
}
minimumDistance := parent.SuggestionsMinimumDistance
if minimumDistance <= 0 {
minimumDistance = 2
return command
}
// GroupRunE is the structured group handler used by the DWS command tree.
func GroupRunE(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return unknownSubcommandError(cmd, strings.TrimSpace(args[0]))
}
lowerCandidate := strings.ToLower(candidate)
shortcutOnly := strings.HasPrefix(candidate, "+")
seen := make(map[string]bool)
var suggestions []string
for _, child := range parent.Commands() {
if !child.IsAvailableCommand() {
continue
}
name := child.Name()
if shortcutOnly && !strings.HasPrefix(name, "+") {
continue
}
matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance ||
strings.HasPrefix(strings.ToLower(name), lowerCandidate)
if !matched {
for _, explicit := range child.SuggestFor {
if strings.EqualFold(candidate, explicit) {
matched = true
break
}
}
}
if matched && !seen[name] {
seen[name] = true
suggestions = append(suggestions, name)
}
}
sort.Strings(suggestions)
return suggestions
return cmd.Help()
}
+50 -16
View File
@@ -28,10 +28,11 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF
if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` {
t.Fatalf("Message = %q", structured.Message)
}
if structured.Hint != `Did you mean "dws chat +chat-messages"?` {
if structured.Hint != `Did you mean "dws chat +chat-messages"? (Run 'dws chat --help' for the full list)` {
t.Fatalf("Hint = %q", structured.Hint)
}
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" {
if len(structured.Actions) != 2 || structured.Actions[0] != "Run 'dws chat --help' for the full list" ||
structured.Actions[1] != "Run 'dws shortcut list --service chat --format json'" {
t.Fatalf("Actions = %#v", structured.Actions)
}
if len(structured.AvailableFlags) != 0 {
@@ -53,21 +54,31 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon
if structured.Message != `unknown subcommand "search" for "dws dev app"` {
t.Fatalf("Message = %q", structured.Message)
}
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" {
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' for the full list" {
t.Fatalf("Actions = %#v", structured.Actions)
}
legacy := &cobra.Command{Use: "legacy", RunE: GroupRunE}
cmdutil.MarkGroup(legacy)
legacy.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
root.AddCommand(legacy)
requireCommandResolutionError(t, validateUnresolvedCommand(legacy, []string{"lisst", "--query", "x"}), "unknown_subcommand")
positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}}
root.AddCommand(positional)
positionalWithChild := &cobra.Command{Use: "query [term]", Args: cobra.ExactArgs(1), Run: func(*cobra.Command, []string) {}}
positionalWithChild.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
root.AddCommand(positionalWithChild)
for name, test := range map[string]struct {
target *cobra.Command
remaining []string
}{
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
"dash terminator": {target: app, remaining: []string{"--", "search"}},
"nil target": {target: nil, remaining: []string{"search"}},
"empty remaining": {target: app},
"cobra help command": {target: root, remaining: []string{"help", "chat"}},
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
"positional close to child command": {target: positionalWithChild, remaining: []string{"lis"}},
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
"dash terminator": {target: app, remaining: []string{"--", "search"}},
"nil target": {target: nil, remaining: []string{"search"}},
"empty remaining": {target: app},
} {
t.Run(name, func(t *testing.T) {
if err := validateUnresolvedCommand(test.target, test.remaining); err != nil {
@@ -108,17 +119,17 @@ func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *test
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
root.AddCommand(group)
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} {
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
}
hint := commandSuggestionHint(group, "missing", "fallback")
if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") {
t.Fatalf("bounded hint = %q", hint)
hint := cmdutil.FormatSubcommandSuggestionHint(group, cmdutil.SuggestSubcommands(group, "alpho"), "fallback")
if strings.Count(hint, `"dws demo `) != cmdutil.MaxCommandSuggestions || strings.Contains(hint, `"dws demo aaa"`) || !strings.Contains(hint, `"dws demo alphx"`) {
t.Fatalf("nearest bounded hint = %q", hint)
}
empty := &cobra.Command{Use: "empty"}
root.AddCommand(empty)
if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" {
if got := cmdutil.FormatSubcommandSuggestionHint(empty, cmdutil.SuggestSubcommands(empty, "missing"), "fallback"); got != "fallback" {
t.Fatalf("fallback hint = %q", got)
}
}
@@ -136,8 +147,8 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat
if isExplicitShortcutCandidate(group, "+missing") {
t.Fatal("nested command was treated as a top-level shortcut service")
}
if got := commandSuggestions(nil, "missing"); got != nil {
t.Fatalf("commandSuggestions(nil) = %#v", got)
if got := cmdutil.SuggestSubcommands(nil, "missing"); got != nil {
t.Fatalf("SuggestSubcommands(nil) = %#v", got)
}
service.SuggestionsMinimumDistance = 1
@@ -146,11 +157,34 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat
SuggestFor: []string{"invented"},
Run: func(*cobra.Command, []string) {},
})
if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" {
if got := cmdutil.SuggestSubcommands(service, "invented"); len(got) != 1 || got[0] != "canonical" {
t.Fatalf("explicit SuggestFor suggestions = %#v", got)
}
}
func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) {
root := &cobra.Command{Use: "dws"}
parent := &cobra.Command{Use: "chat"}
hint := HintSubCmd("send", "use: dws chat message send")
root.AddCommand(parent)
parent.AddCommand(hint)
err := hint.RunE(hint, nil)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("HintSubCmd error = %T %v", err, err)
}
if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" {
t.Fatalf("HintSubCmd error = %#v", structured)
}
if !strings.Contains(structured.Hint, "dws chat message send") || !strings.Contains(structured.Hint, "dws chat --help") {
t.Fatalf("HintSubCmd hint = %q", structured.Hint)
}
if !cmdutil.IsHintOnlyCommand(hint) {
t.Fatal("HintSubCmd lost hint-only identity")
}
}
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
t.Helper()
var structured *apperrors.Error
+2
View File
@@ -16,6 +16,7 @@ package shortcut
import (
"sort"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -72,6 +73,7 @@ func build(shortcuts []Shortcut) []*cobra.Command {
Use: s.Service,
Short: s.Service + " shortcuts",
}
cmdutil.MarkGroup(parent)
byService[s.Service] = parent
order = append(order, s.Service)
}
+6
View File
@@ -24,6 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -472,6 +473,11 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
if got := len(byName["calendar"].Commands()); got != 1 {
t.Errorf("calendar has %d leaves, want 1", got)
}
for name, command := range byName {
if !cmdutil.IsGroup(command) {
t.Errorf("%s shortcut service parent is not marked as a command group", name)
}
}
}
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cmdutil
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
)
// MaxCommandSuggestions keeps typo recovery concise even for products with a
// large command surface.
const MaxCommandSuggestions = 3
type rankedCommandSuggestion struct {
name string
explicit bool
prefix bool
distance int
lengthDelta int
}
// SuggestSubcommands returns at most MaxCommandSuggestions visible canonical
// child names, ranked by reviewed SuggestFor matches, prefix relevance, edit
// distance, length delta, and finally canonical name for deterministic ties.
// Aliases participate in scoring, but recovery always teaches the canonical
// command name.
func SuggestSubcommands(parent *cobra.Command, candidate string) []string {
if parent == nil {
return nil
}
candidate = strings.ToLower(strings.TrimSpace(candidate))
if candidate == "" {
return []string{}
}
minimumDistance := parent.SuggestionsMinimumDistance
if minimumDistance <= 0 {
minimumDistance = 2
}
shortcutOnly := strings.HasPrefix(candidate, "+")
ranked := make([]rankedCommandSuggestion, 0)
for _, child := range parent.Commands() {
if !child.IsAvailableCommand() || (shortcutOnly && !strings.HasPrefix(child.Name(), "+")) {
continue
}
explicit := false
for _, suggestion := range child.SuggestFor {
if strings.EqualFold(candidate, suggestion) {
explicit = true
break
}
}
identities := append([]string{child.Name()}, child.Aliases...)
prefix := false
bestDistance := -1
bestLengthDelta := 0
for _, identity := range identities {
identity = strings.ToLower(strings.TrimSpace(identity))
if identity == "" {
continue
}
if strings.HasPrefix(identity, candidate) {
prefix = true
}
distance := LevenshteinDist(candidate, identity)
lengthDelta := len(identity) - len(candidate)
if lengthDelta < 0 {
lengthDelta = -lengthDelta
}
if bestDistance < 0 || distance < bestDistance || (distance == bestDistance && lengthDelta < bestLengthDelta) {
bestDistance = distance
bestLengthDelta = lengthDelta
}
}
if !explicit && !prefix && (bestDistance < 0 || bestDistance > minimumDistance) {
continue
}
ranked = append(ranked, rankedCommandSuggestion{
name: child.Name(),
explicit: explicit,
prefix: prefix,
distance: bestDistance,
lengthDelta: bestLengthDelta,
})
}
sort.Slice(ranked, func(i, j int) bool {
left, right := ranked[i], ranked[j]
if left.explicit != right.explicit {
return left.explicit
}
if left.prefix != right.prefix {
return left.prefix
}
if left.distance != right.distance {
return left.distance < right.distance
}
if left.lengthDelta != right.lengthDelta {
return left.lengthDelta < right.lengthDelta
}
return left.name < right.name
})
if len(ranked) > MaxCommandSuggestions {
ranked = ranked[:MaxCommandSuggestions]
}
suggestions := make([]string, 0, len(ranked))
for _, suggestion := range ranked {
suggestions = append(suggestions, suggestion.name)
}
return suggestions
}
// FormatSubcommandSuggestionHint renders bounded suggestions and always keeps
// the full-list recovery action visible in the same hint.
func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, fallback string) string {
if len(suggestions) > MaxCommandSuggestions {
suggestions = suggestions[:MaxCommandSuggestions]
}
paths := make([]string, 0, len(suggestions))
for _, suggestion := range suggestions {
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
}
var hint string
switch len(paths) {
case 0:
return fallback
case 1:
hint = "Did you mean " + paths[0] + "?"
default:
hint = "Did you mean one of: " + strings.Join(paths, ", ") + "?"
}
if fallback != "" {
hint += " (" + fallback + ")"
}
return hint
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cmdutil
import (
"slices"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageSuggestSubcommandsRanksNearestAndBoundsResults(t *testing.T) {
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
root.AddCommand(group)
for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} {
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
}
group.AddCommand(&cobra.Command{Use: "alpho-hidden", Hidden: true, Run: func(*cobra.Command, []string) {}})
want := []string{"alpha", "alphi", "alphx"}
if got := SuggestSubcommands(group, "alpho"); !slices.Equal(got, want) {
t.Fatalf("SuggestSubcommands() = %#v, want nearest %#v", got, want)
}
}
func TestCrossPlatformCoverageSuggestSubcommandsUsesAliasesAndReviewedSuggestions(t *testing.T) {
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 1}
root.AddCommand(group)
group.AddCommand(
&cobra.Command{Use: "canonical", Aliases: []string{"metas"}, Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "reviewed", SuggestFor: []string{"legacy-command"}, Run: func(*cobra.Command, []string) {}},
)
if got := SuggestSubcommands(group, "meta"); !slices.Equal(got, []string{"canonical"}) {
t.Fatalf("alias suggestion = %#v, want canonical command", got)
}
if got := SuggestSubcommands(group, "legacy-command"); !slices.Equal(got, []string{"reviewed"}) {
t.Fatalf("reviewed SuggestFor = %#v", got)
}
}
func TestCrossPlatformCoverageGroupRunERemainsConciseWithoutSuggestions(t *testing.T) {
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo"}
root.AddCommand(group)
for _, name := range []string{"alpha", "beta", "gamma", "delta"} {
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
}
err := GroupRunE(group, []string{"zzzzz"})
if err == nil || strings.Contains(err.Error(), "available:") || !strings.Contains(err.Error(), "dws demo --help") {
t.Fatalf("GroupRunE() error = %v, want concise parent-help guidance", err)
}
}
func TestCrossPlatformCoverageFormatSubcommandSuggestionHintDefensivelyBoundsInput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo"}
root.AddCommand(group)
hint := FormatSubcommandSuggestionHint(group, []string{"one", "two", "three", "four"}, "fallback")
if strings.Count(hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(hint, "four") {
t.Fatalf("unbounded formatted hint = %q", hint)
}
}
+6 -10
View File
@@ -28,18 +28,14 @@ import (
const hintOnlyCommandAnnotation = "dws.command.hint_only"
// GroupRunE is a reusable RunE for parent (group) commands that have no
// business logic of their own. With args it returns an error listing available
// subcommands; without args it shows help.
// business logic of their own. With args it returns bounded typo guidance;
// without args it shows help. DWS production trees use pipeline.GroupRunE so
// the same diagnostic is also projected as a structured validation error.
func GroupRunE(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
var names []string
for _, sub := range cmd.Commands() {
if !sub.Hidden {
names = append(names, sub.Name())
}
}
return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help",
args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath())
fallback := fmt.Sprintf("Run '%s --help' for the full list", cmd.CommandPath())
return fmt.Errorf("unknown subcommand %q for %q\n hint: %s",
args[0], cmd.CommandPath(), FormatSubcommandSuggestionHint(cmd, SuggestSubcommands(cmd, args[0]), fallback))
}
return cmd.Help()
}
+238
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
@@ -132,6 +134,242 @@ func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) {
}
}
func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) {
env := isolatedCLIEnv(t)
tests := []struct {
name string
args []string
wantInput string
wantHelp string
wantHint string
wantSuggestion string
wantReason string
}{
{
name: "legacy group typo",
args: []string{"chat", "searchh", "--query", "ignored", "--format", "json"},
wantInput: "searchh",
wantHelp: "dws chat --help",
wantSuggestion: "search",
},
{
name: "nested legacy group typo with child flag",
args: []string{"chat", "message", "liss", "--conversation-id", "cid", "--format", "json"},
wantInput: "liss",
wantHelp: "dws chat message --help",
wantSuggestion: "list",
},
{
name: "legacy group typo without suggestion",
args: []string{"chat", "zzzzz", "--format", "json"},
wantInput: "zzzzz",
wantHelp: "dws chat --help",
},
{
name: "top level typo",
args: []string{"chatt", "--format", "json"},
wantInput: "chatt",
wantHelp: "dws --help",
wantSuggestion: "chat",
},
{
name: "auth group typo",
args: []string{"auth", "sttus", "--format", "json"},
wantInput: "sttus",
wantHelp: "dws auth --help",
wantSuggestion: "status",
},
{
name: "event group typo",
args: []string{"event", "lisst", "--format", "json"},
wantInput: "lisst",
wantHelp: "dws event --help",
wantSuggestion: "list",
},
{
name: "shortcut group typo",
args: []string{"shortcut", "lst", "--format", "json"},
wantInput: "lst",
wantHelp: "dws shortcut --help",
wantSuggestion: "list",
},
{
name: "plus shortcut typo",
args: []string{"chat", "+chat-mesages", "--format", "json"},
wantInput: "+chat-mesages",
wantHelp: "dws chat --help",
wantSuggestion: "+chat-messages",
wantReason: "unknown_shortcut",
},
{
name: "audit group typo",
args: []string{"audit", "tal", "--format", "json"},
wantInput: "tal",
wantHelp: "dws audit --help",
wantSuggestion: "tail",
},
{
name: "hybrid aisearch typo",
args: []string{"aisearch", "persno", "--query", "ignored", "--format", "json"},
wantInput: "persno",
wantHelp: "dws aisearch --help",
wantSuggestion: "person",
},
{
name: "hybrid dev connect typo",
args: []string{"dev", "connect", "statsu", "--format", "json"},
wantInput: "statsu",
wantHelp: "dws dev connect --help",
wantSuggestion: "status",
},
{
name: "hybrid aitable view typo",
args: []string{"aitable", "view", "get", "crad", "--format", "json"},
wantInput: "crad",
wantHelp: "dws aitable view get --help",
wantSuggestion: "card",
},
{
name: "hybrid chat members typo",
args: []string{"chat", "group", "members", "ad", "--format", "json"},
wantInput: "ad",
wantHelp: "dws chat group members --help",
wantSuggestion: "add",
},
{
name: "hybrid doc export typo",
args: []string{"doc", "export", "gte", "--format", "json"},
wantInput: "gte",
wantHelp: "dws doc export --help",
wantSuggestion: "get",
},
{
name: "hybrid report inbox typo",
args: []string{"report", "inbox", "lsit", "--format", "json"},
wantInput: "lsit",
wantHelp: "dws report inbox --help",
wantSuggestion: "list",
},
{
name: "nested app group typo",
args: []string{"mcp", "url", "gt", "--format", "json"},
wantInput: "gt",
wantHelp: "dws mcp url --help",
wantSuggestion: "get",
},
{
name: "calendar group typo",
args: []string{"calendar", "room", "serach", "--format", "json"},
wantInput: "serach",
wantHelp: "dws calendar room --help",
wantSuggestion: "search",
},
{
name: "sheet group typo",
args: []string{"sheet", "range", "reead", "--format", "json"},
wantInput: "reead",
wantHelp: "dws sheet range --help",
wantSuggestion: "read",
},
{
name: "shared compatibility hint",
args: []string{"todo", "create", "--format", "json"},
wantInput: "create",
wantHelp: "dws todo --help",
wantHint: "dws todo task create",
},
{
name: "chat compatibility hint",
args: []string{"chat", "send", "--group", "cid", "--text", "hello", "--format", "json"},
wantInput: "send",
wantHelp: "dws chat --help",
wantHint: "dws chat message send",
},
{
name: "contact compatibility hint",
args: []string{"contact", "user", "info", "--ids", "uid", "--format", "json"},
wantInput: "info",
wantHelp: "dws contact user --help",
wantHint: "dws contact user get",
},
{
name: "calendar compatibility hint",
args: []string{"calendar", "get", "--format", "json"},
wantInput: "get",
wantHelp: "dws calendar --help",
wantHint: "dws calendar event get",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stdout, stderr, err := runCLI(t, env, test.args...)
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 3 {
t.Fatalf("dws %s exit = %v, want 3\nstdout:\n%s\nstderr:\n%s", strings.Join(test.args, " "), err, stdout, stderr)
}
if strings.TrimSpace(stdout) != "" {
t.Fatalf("dws %s wrote stdout:\n%s", strings.Join(test.args, " "), stdout)
}
if strings.Contains(stderr, "available:") {
t.Fatalf("dws %s leaked an unbounded command list:\n%s", strings.Join(test.args, " "), stderr)
}
var payload struct {
Error struct {
Category string `json:"category"`
Reason string `json:"reason"`
Hint string `json:"hint"`
Details struct {
Input string `json:"input"`
Suggestions []string `json:"suggestions"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(stderr), &payload); err != nil {
t.Fatalf("dws %s returned non-JSON stderr: %v\nstderr:\n%s", strings.Join(test.args, " "), err, stderr)
}
wantReason := test.wantReason
if wantReason == "" {
wantReason = "unknown_subcommand"
}
if payload.Error.Category != "validation" || payload.Error.Reason != wantReason {
t.Fatalf("dws %s error = %#v", strings.Join(test.args, " "), payload.Error)
}
if !strings.Contains(payload.Error.Hint, test.wantHelp) {
t.Fatalf("dws %s hint = %q, want parent help %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHelp)
}
if test.wantHint != "" && !strings.Contains(payload.Error.Hint, test.wantHint) {
t.Fatalf("dws %s hint = %q, want authored guidance %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHint)
}
if payload.Error.Details.Input != test.wantInput {
t.Fatalf("dws %s details.input = %q, want %q", strings.Join(test.args, " "), payload.Error.Details.Input, test.wantInput)
}
if len(payload.Error.Details.Suggestions) > 3 {
t.Fatalf("dws %s suggestions = %#v, want at most 3", strings.Join(test.args, " "), payload.Error.Details.Suggestions)
}
if test.wantSuggestion != "" && !slices.Contains(payload.Error.Details.Suggestions, test.wantSuggestion) {
t.Fatalf("dws %s suggestions = %#v, want %q", strings.Join(test.args, " "), payload.Error.Details.Suggestions, test.wantSuggestion)
}
})
}
t.Run("legitimate positional commands remain executable", func(t *testing.T) {
stdout, stderr, err := runCLI(t, env, "schema", "chat message send", "--compact", "--format", "json")
if err != nil || strings.TrimSpace(stdout) == "" {
t.Fatalf("dws schema positional path failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
stdout, stderr, err = runCLI(t, env, "completion", "zsh")
if err != nil || strings.TrimSpace(stdout) == "" {
t.Fatalf("dws completion positional shell failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
stdout, stderr, err = runCLI(t, env, "help", "auth")
if err != nil || !strings.Contains(stdout, "dws auth") {
t.Fatalf("dws help auth failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
})
}
// publicCommandPaths follows only canonical, user-visible Cobra commands.
// Hidden commands and commands marked Deprecated by Cobra are intentionally
// excluded; aliases are not separate command nodes. Parents and leaves are both