diff --git a/.changes/command-typo-guidance.md b/.changes/command-typo-guidance.md new file mode 100644 index 00000000..76a13aa9 --- /dev/null +++ b/.changes/command-typo-guidance.md @@ -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. diff --git a/internal/app/mcp_url_command.go b/internal/app/mcp_url_command.go index 70a02102..c6bf7d45 100644 --- a/internal/app/mcp_url_command.go +++ b/internal/app/mcp_url_command.go @@ -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 } diff --git a/internal/app/root.go b/internal/app/root.go index c35a2979..544d4b16 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -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...) diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index f688c984..00900197 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -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) } } diff --git a/internal/helpers/agoal.go b/internal/helpers/agoal.go index 6337d4aa..d01e2630 100644 --- a/internal/helpers/agoal.go +++ b/internal/helpers/agoal.go @@ -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", diff --git a/internal/helpers/aisearch.go b/internal/helpers/aisearch.go index 92cbe0a6..b83327f3 100644 --- a/internal/helpers/aisearch.go +++ b/internal/helpers/aisearch.go @@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command { return groupRunE(cmd, args) }, } + markGroup(root) // root 和 person 各自定义同一组本地 flag,这样: // - dws aisearch --query xxx ← root 自己能解析 diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 22370853..dfa3e556 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -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 子命令:按属性投影 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 子命令:按属性局部更新 ──────────────────── @@ -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", diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go index b8fbcb41..cab5652d 100644 --- a/internal/helpers/attendance.go +++ b/internal/helpers/attendance.go @@ -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{ diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 5c71ea67..695b644a 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -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", diff --git a/internal/helpers/calendar_coverage_more_test.go b/internal/helpers/calendar_coverage_more_test.go index 5c552e09..1598ec4b 100644 --- a/internal/helpers/calendar_coverage_more_test.go +++ b/internal/helpers/calendar_coverage_more_test.go @@ -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 }) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 94720e8d..da94bccf 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -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 ", @@ -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: "批量查询当前用户的群会话设置", diff --git a/internal/helpers/chat/toolbar.go b/internal/helpers/chat/toolbar.go index 379568f3..3e9367f4 100644 --- a/internal/helpers/chat/toolbar.go +++ b/internal/helpers/chat/toolbar.go @@ -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( diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index b2f70a1b..0c72cbdd 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -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) } } } diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index 0c0f5c03..cc132e41 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command { }, } media.AddCommand(newChatMediaUploadCommand()) + markGroup(media) return media } diff --git a/internal/helpers/chat_personal_emotion.go b/internal/helpers/chat_personal_emotion.go index eb46fe67..26138cbd 100644 --- a/internal/helpers/chat_personal_emotion.go +++ b/internal/helpers/chat_personal_emotion.go @@ -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(), diff --git a/internal/helpers/conference.go b/internal/helpers/conference.go index 2b34ba44..50225b99 100644 --- a/internal/helpers/conference.go +++ b/internal/helpers/conference.go @@ -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", diff --git a/internal/helpers/contact.go b/internal/helpers/contact.go index e97ce6b7..54af5c95 100644 --- a/internal/helpers/contact.go +++ b/internal/helpers/contact.go @@ -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 → 获取该角色下的成员`, 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", diff --git a/internal/helpers/contact_coverage_more_test.go b/internal/helpers/contact_coverage_more_test.go index 3579151b..cb4c5b6c 100644 --- a/internal/helpers/contact_coverage_more_test.go +++ b/internal/helpers/contact_coverage_more_test.go @@ -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 '") { diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index c8d96c35..c4e44962 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -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 客户端覆盖 diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index cc926c01..ab5ae5b0 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -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 <关键词>")) diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index c3db7200..e2f0f95d 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -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", diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index d9143df8..25fab91d 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -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", diff --git a/internal/helpers/doc_style.go b/internal/helpers/doc_style.go index 109a3d56..45c76f50 100644 --- a/internal/helpers/doc_style.go +++ b/internal/helpers/doc_style.go @@ -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: "设置文档背景纯色", diff --git a/internal/helpers/doc_whiteboard.go b/internal/helpers/doc_whiteboard.go index 2ca8e224..3416aaf4 100644 --- a/internal/helpers/doc_whiteboard.go +++ b/internal/helpers/doc_whiteboard.go @@ -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", diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index b42c45ba..e91135a7 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -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: "收藏文档", diff --git a/internal/helpers/file_comment.go b/internal/helpers/file_comment.go index f573de94..425f2224 100644 --- a/internal/helpers/file_comment.go +++ b/internal/helpers/file_comment.go @@ -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", diff --git a/internal/helpers/group_command_declaration_test.go b/internal/helpers/group_command_declaration_test.go new file mode 100644 index 00000000..c0349f58 --- /dev/null +++ b/internal/helpers/group_command_declaration_test.go @@ -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 +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 986e4df4..5afa7a2b 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -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 diff --git a/internal/helpers/hrbrain.go b/internal/helpers/hrbrain.go index 79a7e42b..4a60164a 100644 --- a/internal/helpers/hrbrain.go +++ b/internal/helpers/hrbrain.go @@ -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", diff --git a/internal/helpers/interfaces.go b/internal/helpers/interfaces.go index 062b78e7..19862dd2 100644 --- a/internal/helpers/interfaces.go +++ b/internal/helpers/interfaces.go @@ -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 diff --git a/internal/helpers/live.go b/internal/helpers/live.go index 9e6266ab..bb18fc7c 100644 --- a/internal/helpers/live.go +++ b/internal/helpers/live.go @@ -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", diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index 30793fd2..3c5fa5e1 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -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: "查询指定日历时间范围内的日程", diff --git a/internal/helpers/markdown.go b/internal/helpers/markdown.go index 505126f6..d38943d8 100644 --- a/internal/helpers/markdown.go +++ b/internal/helpers/markdown.go @@ -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(), diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index e3f11ce8..f843ef73 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -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 } diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index c35c41cc..38e5a49f 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -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", diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index cb2979c9..3f8e2bb3 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -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(), diff --git a/internal/helpers/report.go b/internal/helpers/report.go index fffbb505..f0282b1a 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -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", diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 682c4f4f..67865188 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -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, + }), + ) } } diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go index b16ddf33..ddc961fe 100644 --- a/internal/helpers/sheet_comment.go +++ b/internal/helpers/sheet_comment.go @@ -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", diff --git a/internal/helpers/sheet_pivot_table.go b/internal/helpers/sheet_pivot_table.go index ac1a07bc..638bfe91 100644 --- a/internal/helpers/sheet_pivot_table.go +++ b/internal/helpers/sheet_pivot_table.go @@ -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", diff --git a/internal/helpers/sheet_template.go b/internal/helpers/sheet_template.go index ee0de5dd..71063c1c 100644 --- a/internal/helpers/sheet_template.go +++ b/internal/helpers/sheet_template.go @@ -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", diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index a1a8c7c4..70692e56 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -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", diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index d34b57eb..3f73cd70 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -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}) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 641b17b1..ca488373 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -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", diff --git a/internal/helpers/whiteboard.go b/internal/helpers/whiteboard.go index 9ecdc388..7a731c0c 100644 --- a/internal/helpers/whiteboard.go +++ b/internal/helpers/whiteboard.go @@ -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", diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 9fc381f1..4f81c74e 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -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 diff --git a/internal/pat/pat.go b/internal/pat/pat.go index f3684019..4d32cf88 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -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()) diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go index 6f15137d..1378e78f 100644 --- a/internal/pipeline/command_resolution.go +++ b/internal/pipeline/command_resolution.go @@ -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() } diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 31d7bff4..5fecb7eb 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -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 diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go index cab1c429..6073ff5d 100644 --- a/internal/shortcut/register.go +++ b/internal/shortcut/register.go @@ -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) } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 5cbf5704..9fcb7e3d 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -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) { diff --git a/pkg/cmdutil/command_suggestions.go b/pkg/cmdutil/command_suggestions.go new file mode 100644 index 00000000..e2491c5d --- /dev/null +++ b/pkg/cmdutil/command_suggestions.go @@ -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 +} diff --git a/pkg/cmdutil/command_suggestions_test.go b/pkg/cmdutil/command_suggestions_test.go new file mode 100644 index 00000000..af96add3 --- /dev/null +++ b/pkg/cmdutil/command_suggestions_test.go @@ -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) + } +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index 07b9fbc5..d35bbc6b 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -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() } diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go index fbed6e4e..455ca777 100644 --- a/test/smoke/cli_smoke_test.go +++ b/test/smoke/cli_smoke_test.go @@ -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