Compare commits

...
Author SHA1 Message Date
南润 e4cae01603 fix(ci): isolate subprocess coverage output 2026-08-25 13:41:03 +08:00
南润 4ff4151287 Merge remote-tracking branch 'origin/main' into calendar_skill_opt2 2026-08-25 12:56:29 +08:00
南润 9497e40783 ci: rerun after PR merge ref refresh 2026-08-25 12:01:25 +08:00
南润 3f2d104b0e chore(calendar): drop unrelated local ignore 2026-08-25 11:56:35 +08:00
github-actions[bot] 3fd0d97a26 Merge pull request #1110 from DingTalk-Real-AI/codex/fix-command-typo-guidance
fix(cli): add bounded command typo guidance
2026-08-25 02:11:21 +00:00
赤川 71556a19d9 Merge branch 'main' into codex/fix-command-typo-guidance 2026-08-25 09:50:28 +08:00
github-actions[bot] 69f1337316 Merge pull request #1109 from DingTalk-Real-AI/codex/dev-devapp-agoal-shortcuts
feat(shortcut): harden DevApp and Agoal task surfaces
2026-08-24 22:55:59 +00:00
Dennis 01476727e9 feat(shortcut): harden DevApp and Agoal task surfaces 2026-08-25 02:36:18 +08:00
chichuan 61ffc988c5 test: cover command resolution edge cases 2026-08-25 01:33:52 +08:00
chichuan 156be4cee7 fix: preserve guarded flag values during command resolution 2026-08-25 00:50:05 +08:00
chichuan 2a42e282c7 fix: align typo guidance with command framework 2026-08-25 00:50:03 +08:00
chichuan f5378d2f28 test: cover typo guidance CI edges 2026-08-25 00:50:01 +08:00
chichuan ea4a463956 fix: add bounded command typo guidance 2026-08-25 00:49:59 +08:00
github-actions[bot] 8d34acbb38 Merge pull request #1130 from DingTalk-Real-AI/codex/fix-todo-upstream-internal-error
fix(todo): classify upstream failures and validate inputs
2026-08-25 00:44:39 +08:00
南润 ca6f849ab2 Merge remote-tracking branch 'origin/main' into calendar_skill_opt2 2026-08-20 20:42:53 +08:00
南润 b0ea466dc5 docs(calendar): tighten multi-step execution 2026-08-20 20:42:38 +08:00
南润 021fd25678 docs(calendar): bound recovery and cleanup workflows 2026-08-20 14:08:53 +08:00
南润 4987aae380 Merge remote-tracking branch 'origin/main' into calendar_skill_opt1
# Conflicts:
#	skills/multi/dingtalk-calendar/SKILL.md
#	skills/multi/dingtalk-calendar/references/03-meeting.md
#	skills/multi/dingtalk-calendar/references/lite-recipes.md
2026-08-19 10:51:54 +08:00
南润 c8c997fff3 refactor(calendar): align skill with golden routes 2026-08-19 10:43:17 +08:00
119 changed files with 8532 additions and 1279 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
+7 -2
View File
@@ -1102,8 +1102,11 @@ jobs:
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
# Smoke has no production statements and launches a subprocess;
# focused Test owns it, while -cover only adds runtime stderr noise.
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" |
awk '$0 != "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/smoke"'
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
@@ -1325,8 +1328,10 @@ jobs:
changed "$COVERAGE_BASE_REF" HEAD
)"
impacted_output="$(
# Keep the baseline plan symmetric with Coverage (current).
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" HEAD
list "$COVERAGE_BASE_REF" HEAD |
awk '$0 != "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/smoke"'
)"
fi
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
+3 -1
View File
@@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with
## Command framework declaration
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
@@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
+20 -1
View File
@@ -274,6 +274,7 @@ Definition(仅声明;不可编译)
| 层 | 含义 | 今日落点 |
|---|---|---|
| **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) |
| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 |
| **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) |
| **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) |
| **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) |
@@ -310,7 +311,25 @@ Definition(仅声明;不可编译)
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3)
#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`)
`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`:
| 轴 | 允许值 | 语义 |
|---|---|---|
| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 |
| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 |
| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 |
硬规则:
1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。
2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。
3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。
4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。
5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。
#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3)
当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定):
+209 -42
View File
@@ -1,7 +1,57 @@
{
"generated_at": "2026-08-24T12:22:05.108140",
"count": 426,
"generated_at": "2026-08-24T20:14:49.172788",
"count": 437,
"results": [
{
"suite": "semantic",
"service": "agoal",
"command": "+contract-fields",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+obj-template-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+report-statistics-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+report-submit-detail",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+user-rules",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aisearch",
@@ -2473,137 +2523,254 @@
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+create",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+credentials-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+delete",
"risk": "high-risk-write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+disable",
"risk": "write",
"status": "real-ok"
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+enable",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+event-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+event-subscribe",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+member-add",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+member-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+member-remove",
"risk": "write",
"status": "real-ok"
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+permission-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+robot-config",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+update",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-check-approval",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+version-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-status",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+webapp-config",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+webapp-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "semantic",
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the
// old helpers-only AST scan with an invariant over the two real assembly
// products: the deterministic distribution tree and a runtime tree after a
// nested plugin overlay has been merged.
func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) {
distribution := NewSchemaSourceRootCommand()
for _, path := range []string{
"sheet range read",
"pat chmod",
"plugin list",
"chat +chat-messages",
} {
requireFinalCommandPath(t, distribution, path)
}
if err := cobracmd.ValidateGroupTree(distribution); err != nil {
t.Fatalf("distribution command tree GroupPolicy invariant: %v", err)
}
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command {
descriptor := conferencePluginDescriptor()
return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root)
})
runtime := NewRootCommand()
requireFinalCommandPath(t, runtime, "conference camera open")
if err := cobracmd.ValidateGroupTree(runtime); err != nil {
t.Fatalf("runtime command tree GroupPolicy invariant: %v", err)
}
}
func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command {
t.Helper()
command, remaining, err := root.Find(strings.Fields(path))
if err != nil || command == nil || len(remaining) != 0 || command == root {
t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err)
}
return command
}
+6
View File
@@ -21,6 +21,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"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"
@@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
return cmd.Help()
},
}
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
+6 -1
View File
@@ -24,6 +24,7 @@ import (
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
@@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) {
}
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
pruneEmptyPluginGroups(child)
if cmdutil.IsGroup(child) && len(child.Commands()) == 0 {
_, group, err := corecmd.GroupPolicyFor(child)
if err != nil {
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
}
if group && len(child.Commands()) == 0 {
parent.RemoveCommand(child)
}
}
+39 -1
View File
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) {
emptyParent := &cobra.Command{Use: "plugin"}
emptyGroup := &cobra.Command{Use: "empty"}
corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
emptyParent.AddCommand(emptyGroup)
pruneEmptyPluginGroups(emptyParent)
if len(emptyParent.Commands()) != 0 {
t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands())
}
parent := &cobra.Command{Use: "plugin"}
child := &cobra.Command{Use: "group"}
corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
for key := range child.Annotations {
child.Annotations[key] = "malformed"
}
parent.AddCommand(child)
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, "prune plugin group") {
t.Fatalf("pruneEmptyPluginGroups panic = %v", got)
}
}()
pruneEmptyPluginGroups(parent)
}
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
@@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source := cobracmd.NewGroupCommand("plugin", "plugin")
source.Aliases = []string{"one", "two"}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
@@ -17,6 +17,7 @@ import (
"bytes"
stderrors "errors"
"io"
"slices"
"strings"
"testing"
@@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
}
}
func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{
"aisearch", "--query", "Alice", "--yess", "enterprise",
"--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help",
}
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch enterprise" ||
!slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") {
t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected group-middle persistent flag failed: %v", err)
}
}
func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) {
for _, args := range [][]string{
{"aisearch", "--types", "enterprise"},
{"aisearch", "--types", "false", "enterprise"},
{"aisearch", "--types=false", "enterprise"},
} {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) {
t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx)
}
}
}
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
@@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
args []string
wantReason string
wantCommand string
wantHint string
}{
{
name: "unknown shortcut",
@@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
{
name: "unknown aisearch subcommand before protected flag",
args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws aisearch",
wantHint: "dws aisearch enterprise",
},
}
for _, test := range tests {
@@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) {
t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
+6 -2
View File
@@ -23,6 +23,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
@@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" ||
structured.Message != `unknown subcommand "switch" for "dws auth"` ||
structured.Hint != "Run 'dws auth --help' for the full list" {
t.Fatalf("error = %#v, want bounded auth subcommand guidance", err)
}
}
+33 -10
View File
@@ -30,6 +30,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
return nil
},
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
bindPersistentFlags(root, flags)
@@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
navigationGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
hybridGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
navigationGroup(newAuthCommand(patCaller)),
navigationGroup(newProfileCommand()),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
navigationGroup(newSkillCommand()),
hybridGroup(newCacheCommand()),
newCatalogCommand(),
newConfigCommand(),
navigationGroup(newConfigCommand()),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(flags),
newAuditCommand(),
hybridGroup(newRecoveryCommand()),
navigationGroup(newEventCommand(flags)),
navigationGroup(newAuditCommand()),
newCompletionCommand(root),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
usage.NewShortcutCommand(),
navigationGroup(usage.NewShortcutCommand()),
schemaCmd,
mcpCmd,
navigationGroup(mcpCmd),
}
root.AddCommand(utilityCommands...)
+4 -2
View File
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
}
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 425
publicShortcutCount = 436
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 482
schemaPublishedShortcutCount = 493
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 425
publiclyDeliveredShortcutCount = 436
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
+139 -9
View File
@@ -16,9 +16,10 @@
package cobracmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return cmd
}
@@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) {
if dst == nil || src == nil {
return
}
mergeGroupPolicy(dst, src)
if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) {
dst.Short = src.Short
}
@@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) {
}
}
func mergeGroupPolicy(dst, src *cobra.Command) {
dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst)
if err != nil {
panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err))
}
srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src)
if err != nil {
panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err))
}
if len(dst.Commands()) > 0 && !dstOK {
panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath()))
}
if len(src.Commands()) > 0 && !srcOK {
panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath()))
}
if dstOK && !srcOK {
if !isNeutralMergePlaceholder(src) {
panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q",
src.CommandPath(), dst.CommandPath()))
}
return
}
if !dstOK && srcOK {
if !isNeutralMergePlaceholder(dst) {
panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q",
src.CommandPath(), dst.CommandPath()))
}
corecmd.ApplyGroupPolicy(dst, srcPolicy)
return
}
if dstOK && srcOK && dstPolicy != srcPolicy {
// A NavigationOnly/Reject/Sibling source is the framework's neutral
// service scaffold (shortcuts and plugin overlays use it before being
// folded into an owning product root). The destination owns the merged
// command's default action and recovery scope, so preserve its policy.
// Any stronger source declaration would lose behavior during this
// destination-oriented merge and therefore fails closed.
if srcPolicy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v",
dst.CommandPath(), dstPolicy, srcPolicy))
}
return
}
}
// isNeutralMergePlaceholder reports whether cmd contributes metadata only.
// Such a shell may adopt (or be folded into) one typed group declaration.
// Anything executable, parse-affecting, or child-bearing must declare its own
// compatible GroupPolicy so tree assembly cannot silently discard behavior.
func isNeutralMergePlaceholder(cmd *cobra.Command) bool {
if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil ||
cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil ||
cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil ||
cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil ||
cmd.TraverseChildren || cmd.DisableFlagParsing {
return false
}
hasFlags := false
cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
return !hasFlags
}
// ValidateGroupTree checks the final assembled Cobra tree rather than source
// syntax. Every command with children must carry one valid typed GroupPolicy;
// leaves must carry none. This catches dynamically assembled aliases, plugin
// parents, and constructors outside any one source directory.
func ValidateGroupTree(root *cobra.Command) error {
if root == nil {
return fmt.Errorf("cannot validate a nil command tree")
}
return validateGroupNode(root)
}
func validateGroupNode(cmd *cobra.Command) error {
policy, declared, err := corecmd.GroupPolicyFor(cmd)
if err != nil {
return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err)
}
children := cmd.Commands()
if len(children) == 0 {
if declared {
return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy)
}
return nil
}
if !declared {
return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath())
}
if !cmd.Runnable() {
return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode)
}
if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) {
return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath())
}
if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil {
return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil {
return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsReject {
if cmd.Args == nil {
return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath())
}
}
if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) {
return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath())
}
for _, child := range children {
if err := validateGroupNode(child); err != nil {
return err
}
}
return nil
}
func hasAvailableDescendant(cmd *cobra.Command) bool {
for _, child := range cmd.Commands() {
if !child.IsAvailableCommand() {
continue
}
return true
}
return false
}
// ShouldReplaceLeaf decides whether src should replace dst as a leaf command
// based on override priority and local flag count.
func ShouldReplaceLeaf(dst, src *cobra.Command) bool {
+391 -10
View File
@@ -14,8 +14,10 @@
package cobracmd
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
)
@@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) {
t.Fatalf("Short = %q, want %q", cmd.Short, "my group description")
}
if cmd.Args == nil {
t.Fatal("Args should be set (cobra.NoArgs)")
t.Fatal("Args should be set (cobra.ArbitraryArgs)")
}
// Verify Args rejects arguments.
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("expected Args to reject extra arguments")
// Args must reach the shared resolver instead of Cobra's generic arg error.
if err := cmd.Args(cmd, []string{"extra"}); err != nil {
t.Fatalf("Args intercepted command resolution: %v", err)
}
// Verify RunE is set and returns help (no error for valid invocation).
if cmd.RunE == nil {
t.Fatal("RunE should not be nil")
}
policy, ok, err := corecmd.GroupPolicyFor(cmd)
if err != nil || !ok || policy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err)
}
// RunE calls cmd.Help() which should not error.
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE returned unexpected error: %v", err)
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("RunE typo error = %v", err)
}
}
func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) {
t.Run("valid final tree", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
nested := NewGroupCommand("nested", "nested")
nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.AddCommand(nested)
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nil tree", func(t *testing.T) {
if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") {
t.Fatalf("ValidateGroupTree(nil) = %v", err)
}
})
t.Run("children require declaration", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("leaf rejects stale declaration", func(t *testing.T) {
leaf := NewGroupCommand("stale", "stale")
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) {
leaf := &cobra.Command{Use: "stale-deep"}
corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("declared group must stay runnable", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.Args = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("allowed positionals require explicit Args contract", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("validation does not execute positional contracts", func(t *testing.T) {
calls := 0
root := &cobra.Command{
Use: "dws",
Args: func(*cobra.Command, []string) error {
calls++
return nil
},
RunE: func(*cobra.Command, []string) error { return nil },
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
if calls != 0 {
t.Fatalf("ValidateGroupTree executed Args %d times", calls)
}
})
t.Run("deep recovery requires available descendants", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) {
t.Run("malformed policy metadata", func(t *testing.T) {
root := &cobra.Command{
Use: "dws",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("navigation-only execution hook changed", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("hybrid business execution hook removed", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nested validation error is propagated", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(NewGroupCommand("stale", "stale"))
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep recovery accepts an available descendant", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) {
t.Run("copies source declaration", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
policy, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling {
t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err)
}
})
t.Run("typed destination accepts metadata-only source", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Long: "source details"}
MergeCommandTree(dst, src)
if dst.Long != "source details" {
t.Fatalf("Long = %q", dst.Long)
}
})
t.Run("accepts identical declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
})
t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) {
businessCalled := false
dst := &cobra.Command{
Use: "root",
RunE: func(*cobra.Command, []string) error {
businessCalled = true
return nil
},
}
want := corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
}
corecmd.ApplyGroupPolicy(dst, want)
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := NewGroupCommand("root", "neutral scaffold")
src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }})
MergeCommandTree(dst, src)
got, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || got != want {
t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want)
}
if ChildByName(dst, "overlay") == nil {
t.Fatal("neutral scaffold child was not merged")
}
if err := dst.RunE(dst, nil); err != nil || !businessCalled {
t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err)
}
})
t.Run("rejects conflicting declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src := NewGroupCommand("root", "source")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src.Flags().String("source-only", "", "must not be silently discarded")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Args: cobra.NoArgs}
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("rejects undeclared destination group", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
dst.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, &cobra.Command{Use: "root"})
})
t.Run("rejects undeclared source group", func(t *testing.T) {
src := &cobra.Command{Use: "root"}
src.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(&cobra.Command{Use: "root"}, src)
})
}
func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) {
mustPanic := func(t *testing.T, want string, fn func()) {
t.Helper()
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, want) {
t.Fatalf("panic = %v, want substring %q", got, want)
}
}()
fn()
}
malformed := func() *cobra.Command {
return &cobra.Command{
Use: "root",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
}
t.Run("malformed destination policy", func(t *testing.T) {
mustPanic(t, "destination command", func() {
MergeCommandTree(malformed(), &cobra.Command{Use: "root"})
})
})
t.Run("malformed source policy", func(t *testing.T) {
mustPanic(t, "source command", func() {
MergeCommandTree(&cobra.Command{Use: "root"}, malformed())
})
})
t.Run("local flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.Flags().String("local", "", "local parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
t.Run("persistent flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.PersistentFlags().String("persistent", "", "inherited parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
}
func TestNewHiddenGroupCommand(t *testing.T) {
@@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("child merge recursive", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstChild := &cobra.Command{Use: "sub", Short: ""}
dst.AddCommand(dstChild)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcChild := &cobra.Command{Use: "sub", Short: "Merged short"}
src.AddCommand(srcChild)
@@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("leaf replacement by higher priority", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstLeaf := &cobra.Command{Use: "leaf", Short: "old"}
SetOverridePriority(dstLeaf, 1)
dst.AddCommand(dstLeaf)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcLeaf := &cobra.Command{Use: "leaf", Short: "new"}
SetOverridePriority(srcLeaf, 5)
src.AddCommand(srcLeaf)
@@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("new child addition", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "existing"})
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"})
MergeCommandTree(dst, src)
+8 -6
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package corecmd is the shared, dispatch-agnostic base for building leaf
// commands. It concentrates flag registration, the alias/env/default effective
// value fallback chain, required validation, cross-flag constraint declaration
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
// assembly, and Agent Runtime Schema projection.
// Package corecmd is the shared, dispatch-agnostic base for building commands.
// It concentrates typed group policy, flag registration, the alias/env/default
// effective value fallback chain, required validation, cross-flag constraint
// declaration checks + runtime enforcement, SafetySpec-driven confirmation,
// toolArgs assembly, and Agent Runtime Schema projection.
//
// Declaration vs execution (framework rule):
//
@@ -231,7 +231,9 @@ const (
// or assemble business params that belong in Flags/ConstParams.
//
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
// validates this at construction time. corecmd stays dispatch-agnostic and
// validates this at construction time. Non-leaf commands are declared
// separately through ApplyGroupPolicy so leaf execution fields can never be
// configured and then silently ignored. corecmd stays dispatch-agnostic and
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
// body.
type Spec struct {
+218
View File
@@ -0,0 +1,218 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// GroupMode declares whether a command with children is navigation-only or
// also owns business execution. The zero value means the command is a leaf.
type GroupMode string
const (
// GroupNavigationOnly is a parent whose own invocation only renders help.
GroupNavigationOnly GroupMode = "navigation_only"
// GroupHybrid is a runnable business command that also owns children.
GroupHybrid GroupMode = "hybrid"
)
// PositionalsPolicy declares whether a group may consume positional values.
type PositionalsPolicy string
const (
// PositionalsReject makes every unmatched positional token eligible for
// command-resolution recovery rather than business execution.
PositionalsReject PositionalsPolicy = "reject"
// PositionalsAllow reserves positional values for the group's business
// execution. Recovery must therefore be disabled to avoid ambiguity.
PositionalsAllow PositionalsPolicy = "allow"
)
// RecoveryPolicy declares the search scope for unknown-command recovery.
type RecoveryPolicy string
const (
// RecoverySibling suggests only direct children of the current group.
RecoverySibling RecoveryPolicy = "sibling"
// RecoveryDeep may search all descendants of the current group.
RecoveryDeep RecoveryPolicy = "deep"
// RecoveryDisabled leaves positional handling entirely to Cobra or the
// command's business execution.
RecoveryDisabled RecoveryPolicy = "disabled"
)
// GroupPolicy is the typed declaration for every non-leaf command.
//
// Its zero value deliberately means "leaf": callers must declare all three
// fields together for a group. ApplyGroupPolicy compiles the declaration to
// Cobra behavior and private framework metadata; command authors must not
// author parallel kind annotations themselves.
type GroupPolicy struct {
Mode GroupMode
Positionals PositionalsPolicy
Recovery RecoveryPolicy
}
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
// IsZero reports whether p is the leaf declaration.
func (p GroupPolicy) IsZero() bool {
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
}
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
// combinations whose parsing semantics would be ambiguous.
func ValidateGroupPolicy(p GroupPolicy) error {
if p.IsZero() {
return nil
}
switch p.Mode {
case GroupNavigationOnly, GroupHybrid:
default:
return fmt.Errorf("invalid group mode %q", p.Mode)
}
switch p.Positionals {
case PositionalsReject, PositionalsAllow:
default:
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
}
switch p.Recovery {
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
default:
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
}
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
}
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
}
return nil
}
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
// Invalid declarations panic because they are framework construction bugs,
// matching the fail-closed behavior of Spec flag/constraint declarations.
//
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
// groups retain their existing RunE; when they reject positionals, a wrapper
// sends non-empty args to the same unknown-command resolver before invoking
// business execution. When recovery is enabled, rejecting positionals
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
// token with a generic error before command resolution can produce bounded
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
if cmd == nil {
panic("cannot apply GroupPolicy to a nil command")
}
if policy.IsZero() {
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
}
if err := ValidateGroupPolicy(policy); err != nil {
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
}
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
} else if ok && existing != policy {
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
} else if ok {
return
}
if policy.Mode == GroupNavigationOnly {
cmd.Run = nil
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runGroupPolicy(cmd, args, policy)
}
} else if cmd.RunE == nil {
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
businessRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return runGroupPolicy(cmd, args, policy)
}
return businessRunE(cmd, args)
}
}
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
cmd.Args = cobra.ArbitraryArgs
} else if policy.Positionals == PositionalsReject {
cmd.Args = cobra.NoArgs
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
}
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
// false only for a leaf. Malformed private metadata is returned as an error so
// tree assembly can fail closed instead of silently treating a group as a leaf.
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
if cmd == nil || cmd.Annotations == nil {
return GroupPolicy{}, false, nil
}
raw, ok := cmd.Annotations[groupPolicyAnnotation]
if !ok {
return GroupPolicy{}, false, nil
}
parts := strings.Split(raw, "|")
if len(parts) != 3 {
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
}
policy := GroupPolicy{
Mode: GroupMode(parts[0]),
Positionals: PositionalsPolicy(parts[1]),
Recovery: RecoveryPolicy(parts[2]),
}
if err := ValidateGroupPolicy(policy); err != nil {
return GroupPolicy{}, false, err
}
if policy.IsZero() {
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
}
return policy, true, nil
}
func encodeGroupPolicy(policy GroupPolicy) string {
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
}
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
if len(args) == 0 {
return cmd.Help()
}
input := strings.TrimSpace(args[0])
reason := cmdutil.ClassifyCommandResolution(cmd, input)
suggestions := cmdutil.SuggestSubcommands(cmd, input)
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
suggestions = deep
}
}
return cmdutil.NewCommandResolution(
cmd,
input,
reason,
suggestions,
"",
).Err()
}
+323
View File
@@ -0,0 +1,323 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"errors"
"runtime"
"slices"
"strings"
"testing"
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"
)
func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) {
valid := []GroupPolicy{
{},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled},
}
for _, policy := range valid {
if err := ValidateGroupPolicy(policy); err != nil {
t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err)
}
}
invalid := []struct {
name string
policy GroupPolicy
needle string
}{
{name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"},
{name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"},
{name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"},
{name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"},
{name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"},
{name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) {
t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle)
}
})
}
}
func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) {
policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, policy)
got, ok, err := GroupPolicyFor(cmd)
if err != nil || !ok || got != policy {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy)
}
if !cmd.Runnable() || cmd.TraverseChildren {
t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren)
}
if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil {
t.Fatal("PositionalsReject must let command resolution inspect unmatched args")
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("navigation recovery error = %v", err)
}
var help strings.Builder
cmd.SetOut(&help)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("navigation help error = %v", err)
}
if output := help.String(); !strings.Contains(output, "Usage:") {
t.Fatalf("navigation help output = %q", output)
}
// Re-applying the same declaration is idempotent.
ApplyGroupPolicy(cmd, policy)
traversing := &cobra.Command{Use: "traversing", TraverseChildren: true}
ApplyGroupPolicy(traversing, policy)
if !traversing.TraverseChildren {
t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface")
}
leaf := &cobra.Command{Use: "leaf"}
if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() {
t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}}
if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() {
t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
ApplyGroupPolicy(root, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
parent := &cobra.Command{
Use: "search",
RunE: func(*cobra.Command, []string) error { return nil },
}
parent.Flags().String("dimension", "", "parent-only search dimension")
ApplyGroupPolicy(parent, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
childCalled := false
child := &cobra.Command{
Use: "enterprise",
RunE: func(*cobra.Command, []string) error {
childCalled = true
return nil
},
}
parent.AddCommand(child)
root.AddCommand(parent)
root.SetArgs([]string{"search", "--dimension", "name", "enterprise"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") {
t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err)
}
if childCalled {
t.Fatal("parent local flag leaked into child command execution")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) {
called := false
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsAllow,
Recovery: RecoveryDisabled,
})
if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called {
t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) {
called := false
wrapperFrames := 0
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
pcs := make([]uintptr, 32)
frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)])
for {
frame, more := frames.Next()
if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") {
wrapperFrames++
}
if !more {
break
}
}
return nil
},
}
policy := GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsReject,
Recovery: RecoverySibling,
}
ApplyGroupPolicy(cmd, policy)
// Applying the identical declaration must be a no-op. In particular, it
// must not wrap the already wrapped Hybrid RunE a second time.
ApplyGroupPolicy(cmd, policy)
if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("hybrid typo error = %v", err)
}
if called {
t.Fatal("unknown positional must not reach hybrid business RunE")
}
if err := cmd.RunE(cmd, nil); err != nil || !called {
t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err)
}
if wrapperFrames != 1 {
t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
sheet := &cobra.Command{Use: "sheet"}
rangeGroup := &cobra.Command{Use: "range"}
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
ApplyGroupPolicy(rangeGroup, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
sheet.AddCommand(
rangeGroup,
&cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}},
)
ApplyGroupPolicy(sheet, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep,
})
root.AddCommand(sheet)
err := sheet.RunE(sheet, []string{"read"})
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("deep recovery error = %T %v", err, err)
}
if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) ||
structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
t.Fatalf("deep recovery = %#v", structured)
}
if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) {
t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"])
}
err = sheet.RunE(sheet, []string{"+list-sheet"})
structured = nil
if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) {
t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err)
}
if !slices.Equal(structured.Actions, []string{
"Run 'dws sheet --help' for the full list",
"Run 'dws shortcut list --service sheet --format json'",
}) {
t.Fatalf("direct shortcut actions = %#v", structured.Actions)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled,
})
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) {
mustPanic := func(name, needle string, fn func()) {
t.Helper()
t.Run(name, func(t *testing.T) {
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), needle) {
t.Fatalf("panic = %v, want %q", got, needle)
}
}()
fn()
})
}
mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) })
mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) })
mustPanic("invalid policy", "invalid GroupPolicy", func() {
ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{
Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled,
})
})
mustPanic("hybrid must run", "must declare RunE", func() {
ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
})
mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled})
})
mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: "hybrid|reject|unexpected",
},
}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
})
}
func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) {
for name, test := range map[string]struct {
encoded string
needle string
}{
"malformed": {encoded: "hybrid|reject", needle: "malformed"},
"invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"},
"zero policy": {encoded: "||", needle: "must not be zero"},
} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: test.encoded,
},
}
if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) {
t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle)
}
})
}
}
+8 -8
View File
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
// ──────────────────────────────────────────────────────────
func newAgoalCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "agoal",
Short: "Agoal 管理",
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
@@ -39,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
dws agoal obj-template list 获取目标模板列表
dws agoal obj-template create-or-update 新增或更新目标模板`,
RunE: groupRunE,
}
})
// ── strategy: 战略解码管理 ──────────────────────────────────
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
strategyListCmd := &cobra.Command{
Use: "list",
@@ -139,7 +139,7 @@ scopeType 支持:
// ── contract: 经营合约管理 ──────────────────────────────────
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
contractListCmd := &cobra.Command{
Use: "list",
@@ -258,7 +258,7 @@ scopeType 支持:
// ── scorecard: 计分卡管理 ───────────────────────────────────
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
scorecardDetailCmd := &cobra.Command{
Use: "detail",
@@ -394,7 +394,7 @@ scopeType 支持:
// ── user: 用户目标管理 ──────────────────────────────────────
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
userRulesCmd := &cobra.Command{
Use: "rules",
@@ -445,7 +445,7 @@ scopeType 支持:
// ── report: 周月报管理 ──────────────────────────────────────
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
reportListStatisticsCmd := &cobra.Command{
Use: "list-statistics",
@@ -520,7 +520,7 @@ scopeType 支持:
// ── template: 目标模板管理 ──────────────────────────────────
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
objTemplateListCmd := &cobra.Command{
Use: "list",
+1
View File
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
return groupRunE(cmd, args)
},
}
newHybridGroupCommand(root)
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
+26 -24
View File
@@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "aitable",
Short: "AI 表格操作",
Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。
@@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command {
dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`,
RunE: groupRunE,
SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos
}
})
// ── base: Base 管理 ─────────────────────────────────────────
baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}
baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE})
baseGetPrimaryDocIdCmd := &cobra.Command{
Use: "get-primary-doc-id",
@@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
// ── table: 数据表管理 ───────────────────────────────────────
tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}
tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE})
tableGetCmd := &cobra.Command{
Use: "get",
@@ -2223,7 +2223,7 @@ config 结构参考:
// ── field: 字段管理 ─────────────────────────────────────────
fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}
fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE})
fieldGetCmd := &cobra.Command{
Use: "get",
@@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。
// ── record: 记录管理 ────────────────────────────────────────
recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}
recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE})
recordQueryCmd := &cobra.Command{
Use: "query",
@@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── template: 模板搜索 ──────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE})
templateSearchCmd := &cobra.Command{
Use: "search",
@@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── attachment: 附件管理 ──────────────────────────────────────
attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE})
attachmentUploadCmd := &cobra.Command{
Use: "upload",
@@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── view: 视图管理 ───────────────────────────────────────────
viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}
viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE})
viewGetCmd := &cobra.Command{
Use: "get",
@@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
return callAitableTool("get_views", toolArgs)
},
}
newHybridGroupCommand(viewGetCmd)
// ─── view get <attr> 子命令:按属性投影 view 响应 ──────────────
// card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。
@@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。
return callAitableTool("update_view", toolArgs)
},
}
newHybridGroupCommand(viewUpdateCmd)
// ─── view update <attr> 子命令:按属性局部更新 ────────────────────
@@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form: 表单管理 ──────────────────────────────────────────
formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}
formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}
formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}
formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE})
formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE})
formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE})
formListCmd := &cobra.Command{
Use: "list",
@@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ──
formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}
formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE})
formQuestionsCreateCmd := &cobra.Command{
Use: "create",
@@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── workflow: 自动化工作流管理 ────────────────────────────────
workflowCmd := &cobra.Command{
workflowCmd := newGroupCommand(&cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
RunE: groupRunE,
}
})
workflowCreateCmd := &cobra.Command{
Use: "create",
@@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
// ── dashboard: 仪表盘管理 ────────────────────────────────────
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE})
dashboardConfigExampleCmd := &cobra.Command{
Use: "config-example",
@@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── dashboard share: 仪表盘分享管理 ────────────────────────────
dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}
dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE})
dashboardShareGetCmd := &cobra.Command{
Use: "get",
@@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart: 图表管理 ──────────────────────────────────────────
chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}
chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE})
chartWidgetsExampleCmd := &cobra.Command{
Use: "widgets-example",
@@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart share: 图表分享管理 ────────────────────────────────
chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}
chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE})
chartShareGetCmd := &cobra.Command{
Use: "get",
@@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── export / import: 数据导入导出 ────────────────────────────
exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}
exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE})
exportDataCmd := &cobra.Command{
Use: "data",
@@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with
},
})
importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}
importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE})
// ── advperm: 高级权限 / 自定义角色 ────────────────────────────
advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}
advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE})
advpermEnableCmd := &cobra.Command{
Use: "enable",
@@ -7392,7 +7394,7 @@ role-get 自行 merge)。
// ── section: 文件夹与节点管理(导航树组织) ──────────────────────────────
sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}
sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE})
sectionCreateCmd := &cobra.Command{
Use: "create",
@@ -8424,7 +8426,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
+23 -23
View File
@@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "attendance",
Short: "考勤打卡 / 排班 / 统计",
Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。
@@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command {
globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知)
vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`,
RunE: groupRunE,
}
})
// ── record ───────────────────────────────────────────────
attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}
attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE})
attendanceRecordGetCmd := &cobra.Command{
Use: "get",
@@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command {
// ── check ────────────────────────────────────────────────
attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}
attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE})
// MCP tool: query_check_result
attendanceCheckResultCmd := &cobra.Command{
@@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command {
// ── approve ────────────────────────────────────────────────
attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}
attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE})
// 审批类型关键词到 bizType 数字映射
// 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类),
@@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command {
// ── shift ────────────────────────────────────────────────
attendanceShiftCmd := &cobra.Command{
attendanceShiftCmd := newGroupCommand(&cobra.Command{
Use: "shift",
Short: "班次查询",
Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。
返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`,
RunE: groupRunE,
}
})
// MCP tool: batch_get_employee_shifts
attendanceShiftListCmd := &cobra.Command{
@@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command {
// ── class ────────────────────────────────────────────────
attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}
attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE})
// MCP tool: get_class_list
attendanceClassSearchCmd := &cobra.Command{
@@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── adjustment-rule ────────────────────────────────────
attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}
attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE})
// MCP tool: get_adjustment_rule_detail
attendanceAdjustmentGetCmd := &cobra.Command{
@@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── overtime-rule ──────────────────────────────────────
attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}
attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE})
// MCP tool: get_overtime_rule_detail
attendanceOvertimeGetCmd := &cobra.Command{
@@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── group ──────────────────────────────────────────────
attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}
attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE})
// MCP tool: get_simple_groups
attendanceGroupSearchCmd := &cobra.Command{
@@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── selfsetting ─────────────────────────────────────────────
attendanceSelfSettingCmd := &cobra.Command{
attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{
Use: "selfsetting",
Short: "个人规则设置",
Long: `个人规则设置相关命令。
@@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_self_setting
attendanceSelfSettingGetCmd := &cobra.Command{
@@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── globalsetting ────────────────────────────────────────
attendanceGlobalSettingCmd := &cobra.Command{
attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{
Use: "globalsetting",
Short: "全局规则设置(仅管理员)",
Long: `全局规则设置相关命令,仅管理员可以调用。
@@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_global_setting
attendanceGlobalSettingGetCmd := &cobra.Command{
@@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── report ──────────────────────────────────────────────
attendanceReportCmd := &cobra.Command{
attendanceReportCmd := newGroupCommand(&cobra.Command{
Use: "report",
Short: "查询考勤报表和结果",
Long: `考勤 MCP 报表接口,仅对管理员开放
@@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
query-data 根据字段查询考勤数据
query-leave 查询用户假期数据`,
RunE: groupRunE,
}
})
// MCP tool: get_report_columns
reportColumnsCmd := &cobra.Command{
@@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── 假期 vacation ───────────────────────────────────────────────
attendanceVacationCmd := &cobra.Command{
attendanceVacationCmd := newGroupCommand(&cobra.Command{
Use: "vacation",
Short: "假期管理",
Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。
@@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
save-balance 更新员工假期余额
records 查询指定员工假期余额变更记录`,
RunE: groupRunE,
}
})
// ── 假期规则 types ─────────────────────────────────────────
@@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── schedule ──────────────────────────────────────────────
attendanceScheduleCmd := &cobra.Command{
attendanceScheduleCmd := newGroupCommand(&cobra.Command{
Use: "schedule",
Short: "排班管理",
Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。
@@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
import 导入排班记录到排班制考勤组
get 获取指定用户的排班记录`,
RunE: groupRunE,
}
})
// schedule import (generateTurnSchedule)
scheduleImportCmd := &cobra.Command{
@@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── checkin ──────────────────────────────────────────────
checkinCmd := &cobra.Command{
checkinCmd := newGroupCommand(&cobra.Command{
Use: "checkin",
Short: "签到管理",
Long: `签到记录的查询。
子命令:
records 查询指定员工的签到记录`,
RunE: groupRunE,
}
})
// MCP tool: queryUserRecordByStaffIds
checkinRecordsCmd := &cobra.Command{
+13 -200
View File
@@ -2,14 +2,11 @@ package helpers
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -18,10 +15,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,186 +25,9 @@ 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
}
// installUnknownVerbFallback makes `group` emit a consistent warning-style
// "Did you mean" hint whenever the caller types an unknown subcommand under
// that group, regardless of whether extra flags follow. This is a blanket
// safety net that covers every verb we never thought to pre-register via
// calendarInfoHintSubCmd (e.g. `dws calendar room query --min-duration 30`).
//
// Two Cobra knobs make this work together:
// - FParseErrWhitelist.UnknownFlags=true stops pflag from aborting with
// "unknown flag: --xxx" before RunE ever runs.
// - Args=cobra.ArbitraryArgs lets Cobra pass the bad verb through as the
// first positional arg instead of rejecting it.
//
// If the user types a *known* subcommand, Cobra still dispatches to that
// child's RunE as usual; this fallback only fires when resolution stops at
// `group` with leftover args.
func installUnknownVerbFallback(group *cobra.Command) {
group.FParseErrWhitelist.UnknownFlags = true
group.Args = cobra.ArbitraryArgs
// Override HelpFunc so that `<group> <unknown-verb> --help` also shows
// the "unknown subcommand" error instead of silently printing help.
// Cobra intercepts --help before RunE, so without this the fallback
// would never fire when --help is present.
origHelp := group.HelpFunc()
group.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == group {
// HelpFunc receives os.Args[1:] (full arg slice without binary).
// Strip tokens matching the resolved command path to get actual
// leftover args that should be checked for unknown verbs.
depth := len(strings.Fields(cmd.CommandPath())) - 1
leftover := stripCommandPrefix(args, depth)
if bad := findUnknownVerb(cmd, leftover); bad != "" {
printUnknownSubcmdError(cmd, bad)
return
}
origHelp(cmd, args)
return
}
// For non-group commands, render base help then apply the safety
// annotation. Recursion safety hinges on NOT calling
// cmd.Root().HelpFunc(): in test trees calendar IS the root, so that
// would re-enter this wrapper. origHelp was captured before any
// wrapping and is the plain cobra renderer.
origHelp(cmd, args)
cli.RenderSafetyAnnotation(cmd)
})
prev := group.RunE
group.RunE = func(cmd *cobra.Command, args []string) error {
// 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
}
// No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently
// swallows bad flags, scan the original os.Args for flags unregistered
// on this command and report them explicitly.
if flag := findUnknownFlag(cmd); flag != "" {
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", flag)
fmt.Fprintf(os.Stderr, " hint: Run '%s --help' to see available options\n", cmd.CommandPath())
return nil
}
if prev != nil {
return prev(cmd, args)
}
return cmd.Help()
}
}
// findUnknownVerb returns the first positional arg that is not a registered
// subcommand (or alias) of cmd. Returns "" if all args are flags or known.
func findUnknownVerb(cmd *cobra.Command, args []string) string {
for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
isKnown := false
for _, c := range cmd.Commands() {
if c.Name() == a {
isKnown = true
break
}
for _, alias := range c.Aliases {
if alias == a {
isKnown = true
break
}
}
if isKnown {
break
}
}
if !isKnown {
return a
}
}
return ""
}
// printUnknownSubcmdError prints the standard "unknown subcommand" error to
// stderr with available commands and a did-you-mean hint.
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())
}
}
// stripCommandPrefix strips the first `depth` non-flag tokens from args.
// This is needed because Cobra's HelpFunc receives os.Args[1:] (the full arg
// slice without the binary name), including the resolved command path tokens.
// depth should be len(strings.Fields(cmd.CommandPath())) - 1.
func stripCommandPrefix(args []string, depth int) []string {
skipped := 0
for i, a := range args {
if skipped >= depth {
return args[i:]
}
if !strings.HasPrefix(a, "-") {
skipped++
}
}
return nil
}
// findUnknownFlag scans os.Args for flags that are not registered on cmd.
// Returns the first offending flag token (e.g. "--today") or "".
func findUnknownFlag(cmd *cobra.Command) string {
depth := len(strings.Fields(cmd.CommandPath())) - 1
leftover := stripCommandPrefix(os.Args[1:], depth)
for i := 0; i < len(leftover); i++ {
a := leftover[i]
if a == "--" {
break
}
if strings.HasPrefix(a, "--") {
name := a[2:]
if eqIdx := strings.Index(name, "="); eqIdx >= 0 {
name = name[:eqIdx]
}
if name == "help" {
continue
}
if cmd.Flags().Lookup(name) == nil {
return a
}
} else if strings.HasPrefix(a, "-") && a != "-" {
ch := a[1:2]
if ch == "h" {
continue
}
if cmd.Flags().ShorthandLookup(ch) == nil {
return a
}
}
}
return ""
}
func newCalendarCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/calendar.json
// products.calendar). Catalog assembly stamps provenance contract_final.
@@ -224,7 +43,7 @@ func newCalendarCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "calendar",
Short: "日历日程 / 会议室 / 闲忙",
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
@@ -238,11 +57,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 +635,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 +827,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 +1171,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 +1263,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 +1348,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 +1438,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",
@@ -2657,12 +2476,6 @@ func newCalendarCommand() *cobra.Command {
root.AddCommand(eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd)
// Install the unknown-verb fallback on every group command. This covers
// arbitrary typos like `dws calendar room query --min-duration 30` that
// the per-verb calendarInfoHintSubCmd registrations below can't anticipate.
for _, g := range []*cobra.Command{root, eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd} {
installUnknownVerbFallback(g)
}
// Hint subcommands must swallow any extra flags/args the caller passes,
// otherwise `dws calendar list` prints the nice "ambiguous command" hint
// but `dws calendar list --start ...` fails earlier with cobra's
@@ -1,9 +1,10 @@
package helpers
import (
"os"
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -46,16 +47,13 @@ func TestCrossPlatformCoverageCalendarOptionalFlagsRemainingCoverage(t *testing.
func TestCrossPlatformCoverageCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) {
root := &cobra.Command{Use: "calendar"}
group := &cobra.Command{Use: "room"}
group := newGroupCommand(&cobra.Command{Use: "room"})
group.SuggestionsMinimumDistance = 3
group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}})
root.AddCommand(group)
installUnknownVerbFallback(group)
oldArgs := os.Args
os.Args = []string{"dws", "calendar", "room", "--unknown"}
t.Cleanup(func() { os.Args = oldArgs })
if err := group.RunE(group, nil); err != nil {
t.Fatalf("unknown flag fallback: %v", err)
err := group.RunE(group, []string{"serach"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
t.Fatalf("typed suggestion fallback: %#v", err)
}
printUnknownSubcmdError(group, "serach")
}
+11 -30
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -175,42 +176,22 @@ func TestCrossPlatformCoverageBuildRecurrenceCoverage(t *testing.T) {
func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) {
root := &cobra.Command{Use: "calendar"}
group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }}
group := newGroupCommand(&cobra.Command{Use: "room"})
known := &cobra.Command{Use: "search", Aliases: []string{"find"}}
hidden := &cobra.Command{Use: "secret", Hidden: true}
group.AddCommand(known, hidden)
root.AddCommand(group)
installUnknownVerbFallback(group)
_ = group.RunE(group, []string{"unknown"})
_ = group.RunE(group, []string{"--ignored", "search"})
_ = group.RunE(group, nil)
group.HelpFunc()(group, []string{"calendar", "room", "unknown"})
group.HelpFunc()(known, nil)
printUnknownSubcmdError(group, "searhc")
printUnknownSubcmdError(group, "unrelated")
var structured *apperrors.Error
if err := group.RunE(group, []string{"searhc"}); !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
t.Fatalf("typed group recovery = %#v", err)
}
if err := group.RunE(group, nil); err != nil {
t.Fatalf("group help = %v", err)
}
hint := calendarInfoHintSubCmd("query", "use search")
group.AddCommand(hint)
_ = hint.RunE(hint, nil)
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
group.Flags().StringP("known", "k", "", "")
for _, args := range [][]string{
{"dws", "calendar", "room", "--"},
{"dws", "calendar", "room", "--help"},
{"dws", "calendar", "room", "--known=value"},
{"dws", "calendar", "room", "--unknown=value"},
{"dws", "calendar", "room", "-h"},
{"dws", "calendar", "room", "-k", "value"},
{"dws", "calendar", "room", "-x"},
} {
os.Args = args
_ = findUnknownFlag(group)
if err := hint.RunE(hint, nil); err == nil {
t.Fatal("calendar compatibility hint succeeded")
}
nilPrev := &cobra.Command{Use: "empty"}
root.AddCommand(nilPrev)
installUnknownVerbFallback(nilPrev)
os.Args = []string{"dws", "calendar", "empty"}
_ = nilPrev.RunE(nilPrev, nil)
}
+17 -16
View File
@@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "chat",
Aliases: []string{"im"},
Short: "群聊 / 消息 / 机器人",
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
RunE: groupRunE,
}
})
chatChmodCmd := &cobra.Command{
Use: "chmod <scope>",
@@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command {
},
})
chatDataAuthCmd := &cobra.Command{
chatDataAuthCmd := newGroupCommand(&cobra.Command{
Use: "data-auth",
Short: "授予 chat 数据读取权限",
Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`,
RunE: groupRunE,
}
})
chatDataAuthCrossOrgCmd := &cobra.Command{
Use: "cross-org",
Short: "授予跨组织 chat 数据访问权限",
@@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command {
// ── group 子命令 ──────────────────────────────────────────
chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}
chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE})
chatGroupCreateCmd := &cobra.Command{
Use: "create",
@@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command {
return callMCPTool("get_group_members", toolArgs)
},
}
newHybridGroupCommand(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: "批量查询当前用户的群会话设置",
+9 -1
View File
@@ -13,7 +13,10 @@
package chat
import "github.com/spf13/cobra"
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
)
func newChatToolbarCommand() *cobra.Command {
toolbarCmd := &cobra.Command{
@@ -22,6 +25,11 @@ func newChatToolbarCommand() *cobra.Command {
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
RunE: groupRunE,
}
corecmd.ApplyGroupPolicy(toolbarCmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
toolbarCmd.DisableAutoGenTag = true
toolbarCmd.AddCommand(
+7 -2
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err)
}
if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint)
}
}
}
+1
View File
@@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command {
},
}
media.AddCommand(newChatMediaUploadCommand())
newHybridGroupCommand(media)
return media
}
+2 -2
View File
@@ -13,12 +13,12 @@ import (
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
cmd := newGroupCommand(&cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
})
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
+8 -8
View File
@@ -28,19 +28,19 @@ func newCollegeContactCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "college-contact",
Short: "高校通讯录",
Long: `钉钉高校通讯录扩展:查询高校组织架构/院系/师生信息等。`,
Hidden: true,
RunE: groupRunE,
}
})
// ════════════════════════════════════════════════════════════
// dept 子命令组 — 部门管理
// ════════════════════════════════════════════════════════════
deptCmd := &cobra.Command{Use: "dept", Short: "部门管理", RunE: groupRunE}
deptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门管理", RunE: groupRunE})
getStandardStructureCmd := &cobra.Command{
Use: "get-standard-structure",
@@ -1574,7 +1574,7 @@ func newCollegeContactCommand() *cobra.Command {
// employee 子命令组 — 员工管理
// ════════════════════════════════════════════════════════════
employeeCmd := &cobra.Command{Use: "employee", Short: "员工管理", RunE: groupRunE}
employeeCmd := newGroupCommand(&cobra.Command{Use: "employee", Short: "员工管理", RunE: groupRunE})
getEmployeeDetailCmd := &cobra.Command{
Use: "get-detail",
@@ -2654,7 +2654,7 @@ func newCollegeContactCommand() *cobra.Command {
// alumni 子命令组 — 校友管理
// ════════════════════════════════════════════════════════════
alumniCmd := &cobra.Command{Use: "alumni", Short: "校友管理", RunE: groupRunE}
alumniCmd := newGroupCommand(&cobra.Command{Use: "alumni", Short: "校友管理", RunE: groupRunE})
getAlumniDeptTreeCmd := &cobra.Command{
Use: "get-dept-tree",
@@ -4120,7 +4120,7 @@ func newCollegeContactCommand() *cobra.Command {
// graduate 子命令组 — 毕业年级管理
// ════════════════════════════════════════════════════════════
graduateCmd := &cobra.Command{Use: "graduate", Short: "毕业年级管理", RunE: groupRunE}
graduateCmd := newGroupCommand(&cobra.Command{Use: "graduate", Short: "毕业年级管理", RunE: groupRunE})
// ── query-graduate-years ──────────────────────────────────
queryGraduateYearsCmd := &cobra.Command{
@@ -5346,11 +5346,11 @@ func newCollegeContactCommand() *cobra.Command {
commitRestoreCmd.Flags().String("request-no", "", "异步任务请求号")
// ── group 子命令组 ────────────────────────────────────────
groupCmd := &cobra.Command{
groupCmd := newGroupCommand(&cobra.Command{
Use: "group",
Short: "规则管理",
RunE: groupRunE,
}
})
// ── query-group-rule ─────────────────────────────────────
queryGroupRuleCmd := &cobra.Command{
+3 -2
View File
@@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command {
直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`,
RunE: runUnavailable,
}
newHybridGroupCommand(root)
meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}
meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE})
meetingCreateCmd := &cobra.Command{
Use: "reserve",
@@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command {
root.AddCommand(meetingCmd)
// member 子命令组 — 成员管理
memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}
memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE})
memberInviteCmd := &cobra.Command{
Use: "invite",
+17 -17
View File
@@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "contact",
Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系",
Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。
@@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command {
- contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等)
- contact user dismission search: 离职员工列表查询`,
RunE: groupRunE,
}
})
userCmd := &cobra.Command{
userCmd := newGroupCommand(&cobra.Command{
Use: "user",
Short: "人员管理",
Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。
@@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
RunE: groupRunE,
}
})
contactUserGetSelfCmd := &cobra.Command{
Use: "get-self",
@@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command {
},
})
relationCmd := &cobra.Command{Use: "relation",
relationCmd := newGroupCommand(&cobra.Command{Use: "relation",
Short: "人员关系查询",
Long: `查询钉钉人员关系:特别关注人。`,
RunE: groupRunE}
RunE: groupRunE})
contactRelationListMyFollowingsCmd := &cobra.Command{
Use: "list-my-followings",
@@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command {
// ── label 角色 ──────────────────────────────────────────────────
contactLabelCmd := &cobra.Command{
contactLabelCmd := newGroupCommand(&cobra.Command{
Use: "label",
Aliases: []string{"role"},
Short: "角色查询",
@@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command {
2. 从返回结果中匹配目标角色名称及 labelId
3. contact label list-members --id <labelId> → 获取该角色下的成员`,
RunE: groupRunE,
}
})
runContactLabelList := func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
@@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command {
contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd)
contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}
contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE})
contactDeptSearchCmd := &cobra.Command{
Use: "search",
@@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command {
})
// ── user profile 用户档案(花名册) ────────────────────────────────────
contactUserProfileCmd := &cobra.Command{
contactUserProfileCmd := newGroupCommand(&cobra.Command{
Use: "profile",
Short: "用户档案(花名册)",
Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。
@@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command {
- contact user get: 组织管理信息(部门、主管、管理员权限)
- contact user profile get: 个人档案信息(学历、家庭、银行卡等)`,
RunE: groupRunE,
}
})
contactUserProfileFieldsCmd := &cobra.Command{
Use: "fields",
@@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。
contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd)
// ── user dismission 离职员工 ───────────────────────────────────────────
contactUserDismissionCmd := &cobra.Command{
contactUserDismissionCmd := newGroupCommand(&cobra.Command{
Use: "dismission",
Short: "离职员工查询",
Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`,
RunE: groupRunE,
}
})
contactUserDismissionSearchCmd := &cobra.Command{
Use: "search",
@@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。
// ── org 企业管理 ──────────────────────────────────────────────────
contactOrgCmd := &cobra.Command{
contactOrgCmd := newGroupCommand(&cobra.Command{
Use: "org",
Short: "企业管理",
Long: `企业管理:创建企业。
@@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。
- 创建企业专属账号 → contact account create
- 邀请员工加入企业 → contact user invite`,
RunE: groupRunE,
}
})
contactOrgCreateCmd := &cobra.Command{
Use: "create",
@@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。
// ── account 企业账号管理 ──────────────────────────────────────────
contactAccountCmd := &cobra.Command{
contactAccountCmd := newGroupCommand(&cobra.Command{
Use: "account",
Short: "企业账号管理",
Long: "企业账号管理:创建或更新企业专属账号。",
RunE: groupRunE,
}
})
contactAccountCreateCmd := &cobra.Command{
Use: "create",
@@ -1,9 +1,11 @@
package helpers
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T
if err := hint.RunE(hint, []string{"--help"}); err != nil {
t.Fatalf("hint help: %v", err)
}
if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") {
t.Fatalf("hint guidance err=%v", err)
err := hint.RunE(hint, []string{"unexpected"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") {
t.Fatalf("hint guidance err=%#v", structured)
}
if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") {
+3 -27
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -30,30 +30,6 @@ func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T)
_ = isNumericUserID(value)
}
root := &cobra.Command{Use: "calendar"}
known := &cobra.Command{Use: "event", Aliases: []string{"e"}}
hidden := &cobra.Command{Use: "hidden", Hidden: true}
root.AddCommand(known, hidden)
for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} {
_ = findUnknownVerb(root, args)
}
printUnknownSubcmdError(root, "evnt")
for _, depth := range []int{0, 1, 3} {
_ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth)
}
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
root.Flags().StringP("known", "k", "", "")
for _, args := range [][]string{
{"dws", "calendar", "--known=x"},
{"dws", "calendar", "--unknown"},
{"dws", "calendar", "-z"},
{"dws", "calendar", "--", "--ignored"},
} {
os.Args = args
_ = findUnknownFlag(root)
}
for _, event := range []any{
nil,
map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}},
@@ -284,7 +260,7 @@ func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) {
group := &cobra.Command{Use: "range"}
group.AddCommand(&cobra.Command{Use: "read"})
parent.AddCommand(group)
_ = deepSuggestSubcommand(parent, "read")
_ = deepSuggestSubcommand(parent, "missing")
_ = cmdutil.SuggestDescendantSubcommands(parent, "read")
_ = cmdutil.SuggestDescendantSubcommands(parent, "missing")
_ = time.Now()
}
+2 -3
View File
@@ -16,7 +16,6 @@ package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -71,7 +70,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
newGroupCommand(root)
doc := &cobra.Command{
Use: "doc",
@@ -83,7 +82,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(doc)
newGroupCommand(doc)
doc.AddCommand(newDevDocSearchCommand(runner))
root.AddCommand(
+9 -10
View File
@@ -23,7 +23,6 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -140,7 +139,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
newGroupCommand(root)
webapp := &cobra.Command{
Use: "webapp",
@@ -152,7 +151,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(webapp)
newGroupCommand(webapp)
webapp.AddCommand(
newDevAppWebappGetCommand(runner),
newDevAppWebappConfigCommand(runner),
@@ -168,7 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(permission)
newGroupCommand(permission)
permission.AddCommand(
newDevAppPermissionListCommand(runner),
newDevAppPermissionAddCommand(runner),
@@ -185,7 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(credentials)
newGroupCommand(credentials)
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
member := &cobra.Command{
@@ -198,7 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(member)
newGroupCommand(member)
member.AddCommand(
newDevAppMemberListCommand(runner),
newDevAppMemberAddCommand(runner),
@@ -215,7 +214,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(security)
newGroupCommand(security)
security.AddCommand(newDevAppSecurityConfigCommand(runner))
robot := &cobra.Command{
@@ -228,7 +227,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(robot)
newGroupCommand(robot)
robot.AddCommand(
newDevAppRobotSubmitCommand(runner),
newDevAppRobotResultCommand(runner),
@@ -248,7 +247,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(version)
newGroupCommand(version)
version.AddCommand(
newDevAppVersionCreateCommand(runner),
newDevAppVersionListCommand(runner),
@@ -268,7 +267,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(event)
newGroupCommand(event)
event.AddCommand(
newDevAppEventListCommand(runner),
newDevAppEventSubscribeCommand(runner),
+1
View File
@@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
)
newHybridGroupCommand(cmd)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
+3 -3
View File
@@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "devdoc",
Short: "开放平台文档搜索",
Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`,
RunE: groupRunE,
}
})
articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}
articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE})
articleCmd.AddCommand(newDevdocArticleSearchCommand())
root.AddCommand(articleCmd)
root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>"))
+3 -3
View File
@@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "ding",
Short: "DING 消息 / 发送 / 撤回",
Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`,
RunE: groupRunE,
}
})
dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}
dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE})
dingMessageSendCmd := &cobra.Command{
Use: "send",
+17 -15
View File
@@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "doc",
Short: "钉钉文档管理",
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
@@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command {
文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`,
RunE: groupRunE,
}
})
searchCmd := &cobra.Command{
Use: "search",
@@ -1826,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}
fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE})
fileCreateCmd := &cobra.Command{
Use: "create",
@@ -1904,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE})
folderCreateCmd := &cobra.Command{
Use: "create",
@@ -2063,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
blockCmd := &cobra.Command{
blockCmd := newGroupCommand(&cobra.Command{
Use: "block",
Short: "块级编辑",
Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`,
RunE: groupRunE,
}
})
blockListCmd := &cobra.Command{
Use: "list",
@@ -2844,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
_ = renameCmd.Flags().MarkHidden("title")
// ── media (文档媒体/附件) ────────────────────────────────
mediaCmd := &cobra.Command{
mediaCmd := newGroupCommand(&cobra.Command{
Use: "media",
Short: "文档媒体 / 附件管理",
Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`,
RunE: groupRunE,
}
})
mediaDownloadCmd := &cobra.Command{
Use: "download",
@@ -3036,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "文档评论 / 评论管理",
Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`,
RunE: groupRunE,
}
})
commentListCmd := &cobra.Command{
Use: "list",
@@ -3545,13 +3545,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
permissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档协作权限管理",
Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`,
RunE: groupRunE,
}
})
permissionAddCmd := &cobra.Command{
Use: "add",
@@ -4260,6 +4260,7 @@ CLI 内部自动完成全部流程:
_ = exportCmd.Flags().MarkHidden("file-id")
exportCmd.AddCommand(exportGetCmd)
newHybridGroupCommand(exportCmd)
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
importCmd := &cobra.Command{
@@ -4356,14 +4357,15 @@ CLI 内部自动完成全部流程:
})
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importCmd.AddCommand(importGetCmd)
newHybridGroupCommand(importCmd)
// ── doc version 子命令组 ──
versionCmd := &cobra.Command{
versionCmd := newGroupCommand(&cobra.Command{
Use: "version",
Short: "文档历史版本管理",
Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
})
versionSaveCmd := &cobra.Command{
Use: "save",
@@ -4549,7 +4551,7 @@ CLI 内部自动完成全部流程:
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
// ── template 子命令组 ──────────────────────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE})
templateListCmd := &cobra.Command{
Use: "list",
+6 -6
View File
@@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
styleCmd := newGroupCommand(&cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
@@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command {
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
})
coverCmd := &cobra.Command{
coverCmd := newGroupCommand(&cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
})
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
@@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command {
},
})
backgroundCmd := &cobra.Command{
backgroundCmd := newGroupCommand(&cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
})
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
+2 -2
View File
@@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
})
insertCmd := &cobra.Command{
Use: "insert",
+11 -11
View File
@@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command {
},
},
})
driveCmd := &cobra.Command{
driveCmd := newGroupCommand(&cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
RunE: groupRunE,
}
})
driveListCmd := &cobra.Command{
Use: "list",
@@ -2030,14 +2030,14 @@ func newDriveCommand() *cobra.Command {
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
// ── drive permission (文档节点权限管理) ──
drivePermissionCmd := &cobra.Command{
drivePermissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档节点权限管理",
Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。
注意: 仅适用于文档空间节点,不适用于钉盘文件。`,
RunE: groupRunE,
}
})
drivePermAddCmd := &cobra.Command{
Use: "add",
@@ -2806,12 +2806,12 @@ func newDriveCommand() *cobra.Command {
_ = driveRenameCmd.Flags().MarkHidden("title")
// ── drive recycle 子命令组 ──
recycleCmd := &cobra.Command{
recycleCmd := newGroupCommand(&cobra.Command{
Use: "recycle",
Short: "钉盘回收站管理",
Long: `管理钉盘回收站:查看回收站列表、还原回收项。`,
RunE: groupRunE,
}
})
recycleListCmd := &cobra.Command{
Use: "list",
@@ -2921,7 +2921,7 @@ func newDriveCommand() *cobra.Command {
// ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)──
// folder create → dws wiki node create --type folder
driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}
driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE})
driveFolderCreateCmd := &cobra.Command{
Use: "create",
Short: "创建文件夹(deprecated)",
@@ -2951,12 +2951,12 @@ func newDriveCommand() *cobra.Command {
driveFolderCmd.AddCommand(driveFolderCreateCmd)
// ── drive publish (文件互联网公开发布管理) ──
drivePublishCmd := &cobra.Command{
drivePublishCmd := newGroupCommand(&cobra.Command{
Use: "publish",
Short: "文件互联网公开发布管理",
Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`,
RunE: groupRunE,
}
})
drivePublishSetCmd := &cobra.Command{
Use: "set",
@@ -3251,11 +3251,11 @@ func newDriveCommand() *cobra.Command {
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := &cobra.Command{
driveStarCmd := newGroupCommand(&cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
}
})
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
+10 -10
View File
@@ -32,19 +32,19 @@ func newEduAppCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "edu-app",
Short: "家校应用",
Long: `钉钉家校应用:AI消息总结、家校任务查询、成绩单管理、通知确认、奖状颁发、作业管理等。`,
Hidden: true,
RunE: groupRunE,
}
})
// ════════════════════════════════════════════════════════════
// message 子命令组 — AI 消息
// ════════════════════════════════════════════════════════════
messageCmd := &cobra.Command{Use: "message", Short: "AI 消息管理", RunE: groupRunE}
messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "AI 消息管理", RunE: groupRunE})
messageSummaryListCmd := &cobra.Command{
Use: "summary-list",
@@ -128,7 +128,7 @@ func newEduAppCommand() *cobra.Command {
// task 子命令组 — 家校任务
// ════════════════════════════════════════════════════════════
taskCmd := &cobra.Command{Use: "task", Short: "家校任务管理", RunE: groupRunE}
taskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "家校任务管理", RunE: groupRunE})
taskPublishListCmd := &cobra.Command{
Use: "publish-list",
@@ -339,7 +339,7 @@ func newEduAppCommand() *cobra.Command {
// report 子命令组 — 成绩单
// ════════════════════════════════════════════════════════════
reportCmd := &cobra.Command{Use: "report", Short: "成绩单管理", RunE: groupRunE}
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "成绩单管理", RunE: groupRunE})
reportGetCmd := &cobra.Command{
Use: "get",
@@ -673,7 +673,7 @@ func newEduAppCommand() *cobra.Command {
// notice 子命令组 — 通知
// ════════════════════════════════════════════════════════════
noticeCmd := &cobra.Command{Use: "notice", Short: "通知管理", RunE: groupRunE}
noticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "通知管理", RunE: groupRunE})
noticeConfirmCmd := &cobra.Command{
Use: "confirm",
@@ -1252,7 +1252,7 @@ func newEduAppCommand() *cobra.Command {
// circle 子命令组 — 班级圈
// ════════════════════════════════════════════════════════════
circleCmd := &cobra.Command{Use: "circle", Short: "班级圈管理", RunE: groupRunE}
circleCmd := newGroupCommand(&cobra.Command{Use: "circle", Short: "班级圈管理", RunE: groupRunE})
circlePostsCmd := &cobra.Command{
Use: "posts",
@@ -1326,7 +1326,7 @@ func newEduAppCommand() *cobra.Command {
// card 子命令组 — 打卡
// ════════════════════════════════════════════════════════════
cardCmd := &cobra.Command{Use: "card", Short: "打卡管理", RunE: groupRunE}
cardCmd := newGroupCommand(&cobra.Command{Use: "card", Short: "打卡管理", RunE: groupRunE})
cardUpdateCmd := &cobra.Command{
Use: "update",
@@ -1698,7 +1698,7 @@ func newEduAppCommand() *cobra.Command {
// diploma 子命令组 — 奖状
// ════════════════════════════════════════════════════════════
diplomaCmd := &cobra.Command{Use: "diploma", Short: "奖状管理", RunE: groupRunE}
diplomaCmd := newGroupCommand(&cobra.Command{Use: "diploma", Short: "奖状管理", RunE: groupRunE})
diplomaCreateCmd := &cobra.Command{
Use: "create",
@@ -2290,7 +2290,7 @@ func newEduAppCommand() *cobra.Command {
// homework 子命令组 — 作业
// ════════════════════════════════════════════════════════════
homeworkCmd := &cobra.Command{Use: "homework", Short: "作业管理", RunE: groupRunE}
homeworkCmd := newGroupCommand(&cobra.Command{Use: "homework", Short: "作业管理", RunE: groupRunE})
homeworkCreateCmd := &cobra.Command{
Use: "create",
+6 -6
View File
@@ -32,19 +32,19 @@ func newEduContactCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "edu-contact",
Short: "家校通讯录",
Long: `钉钉家校通讯录扩展:查询学校组织/班级/家庭关系/教师信息等。`,
Hidden: true,
RunE: groupRunE,
}
})
// ════════════════════════════════════════════════════════════
// school 子命令组 — 学校/组织级查询
// ════════════════════════════════════════════════════════════
schoolCmd := &cobra.Command{Use: "school", Short: "学校/组织管理", RunE: groupRunE}
schoolCmd := newGroupCommand(&cobra.Command{Use: "school", Short: "学校/组织管理", RunE: groupRunE})
schoolRolesCmd := &cobra.Command{
Use: "roles",
@@ -315,7 +315,7 @@ func newEduContactCommand() *cobra.Command {
// class 子命令组 — 班级级查询
// ════════════════════════════════════════════════════════════
classCmd := &cobra.Command{Use: "class", Short: "班级管理", RunE: groupRunE}
classCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班级管理", RunE: groupRunE})
classDetailCmd := &cobra.Command{
Use: "detail",
@@ -1632,7 +1632,7 @@ func newEduContactCommand() *cobra.Command {
// family 子命令组 — 家庭关系查询
// ════════════════════════════════════════════════════════════
familyCmd := &cobra.Command{Use: "family", Short: "家庭关系查询", RunE: groupRunE}
familyCmd := newGroupCommand(&cobra.Command{Use: "family", Short: "家庭关系查询", RunE: groupRunE})
familyChildrenCmd := &cobra.Command{
Use: "children",
@@ -1720,7 +1720,7 @@ func newEduContactCommand() *cobra.Command {
// teacher 子命令组 — 教师查询
// ════════════════════════════════════════════════════════════
teacherCmd := &cobra.Command{Use: "teacher", Short: "教师管理", RunE: groupRunE}
teacherCmd := newGroupCommand(&cobra.Command{Use: "teacher", Short: "教师管理", RunE: groupRunE})
teacherClassesCmd := &cobra.Command{
Use: "classes",
+4 -4
View File
@@ -33,19 +33,19 @@ func newEduFamilyGroupCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "edu-familygroup",
Short: "家庭群",
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
Hidden: true,
RunE: groupRunE,
}
})
// ════════════════════════════════════════════════════════════
// group 子命令组 — 家庭群读操作
// ════════════════════════════════════════════════════════════
groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}
groupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE})
groupCheckExistsCmd := &cobra.Command{
Use: "check-exists",
@@ -158,7 +158,7 @@ func newEduFamilyGroupCommand() *cobra.Command {
// manage 子命令组 — 家庭群写操作
// ════════════════════════════════════════════════════════════
manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}
manageCmd := newGroupCommand(&cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE})
manageCreateCmd := &cobra.Command{
Use: "create",
+5 -5
View File
@@ -31,19 +31,19 @@ func newEduGroupCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "edu-group",
Short: "家校群",
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
Hidden: true,
RunE: groupRunE,
}
})
// ════════════════════════════════════════════════════════════
// student-group 子命令组 — 师生群管理
// ════════════════════════════════════════════════════════════
studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}
studentGroupCmd := newGroupCommand(&cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE})
studentGroupInfoCmd := &cobra.Command{
Use: "info",
@@ -395,7 +395,7 @@ func newEduGroupCommand() *cobra.Command {
// class-group 子命令组 — 班级群(家校群)会话管理
// ════════════════════════════════════════════════════════════
classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}
classGroupCmd := newGroupCommand(&cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE})
classGroupConversationIDCmd := &cobra.Command{
Use: "conversation-id",
@@ -606,7 +606,7 @@ func newEduGroupCommand() *cobra.Command {
// batch 子命令组 — 批量操作
// ════════════════════════════════════════════════════════════
batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}
batchCmd := newGroupCommand(&cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE})
batchCheckClassGroupCmd := &cobra.Command{
Use: "check-student-group",
+2 -2
View File
@@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag {
// doc comment, sheet comment, and drive comment. The public command belongs to
// Drive, while the implementation routes to the shared doc-comment MCP server.
func newDriveFileCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
})
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
+39 -4
View File
@@ -15,15 +15,16 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/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 reusable command-resolution and flag
// utilities in cmdutil.
var (
groupRunE = cmdutil.GroupRunE
hintSubCmd = cmdutil.HintSubCmd
@@ -38,6 +39,40 @@ var (
helperAfter = time.After
)
// newGroupCommand declares the ordinary navigation policy used by helper
// command containers. The unified framework compiles this declaration into
// Cobra behavior and command-resolution metadata.
func newGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
// newDeepGroupCommand declares a navigation container whose typo recovery may
// teach exact descendant paths (for example sheet read -> sheet range read).
func newDeepGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoveryDeep,
})
return command
}
// newHybridGroupCommand declares a business command that also owns children.
// Its existing RunE remains the command's default action.
func newHybridGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
// Deps holds shared dependencies injected from the host application.
type Deps struct {
Caller edition.ToolCaller
+5 -5
View File
@@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "hrbrain",
Short: "组织大脑:人才池、员工档案与人才搜索",
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
@@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command {
dws hrbrain search employees-structured 使用高级条件搜索人员
dws hrbrain search fields 获取高级搜索字段列表`,
RunE: groupRunE,
}
})
// ── talent-pool: 人才池管理 ────────────────────────────────
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE})
talentPoolListCmd := &cobra.Command{
Use: "list",
@@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command {
// ── profile: 员工档案管理 ──────────────────────────────────
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE})
profileMetadataCmd := &cobra.Command{
Use: "metadata",
@@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command {
// ── search: 人才搜索 ─────────────────────────────────────
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE})
employeeSearchCmd := &cobra.Command{
Use: "employees",
+2 -1
View File
@@ -72,7 +72,8 @@ 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)
out = append(out, command)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Use < out[j].Use
+3 -3
View File
@@ -20,14 +20,14 @@ func newLiveCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "live",
Short: "直播列表 / 信息",
Long: `查看钉钉直播:列出我的直播记录。`,
RunE: groupRunE,
}
})
streamCmd := &cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE}
streamCmd := newGroupCommand(&cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE})
streamListCmd := &cobra.Command{
Use: "list",
+19 -19
View File
@@ -113,14 +113,14 @@ func newMailCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "mail",
Short: "邮箱 / 邮件收发",
Long: `管理钉钉企业邮箱:查询邮箱地址、搜索邮件、查看邮件、发送邮件、获取会话(thread)、列举文件夹、列举标签。`,
RunE: groupRunE,
}
})
mailboxCmd := &cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE}
mailboxCmd := newGroupCommand(&cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE})
mailboxListCmd := &cobra.Command{
Use: "list",
@@ -263,7 +263,7 @@ func newMailCommand() *cobra.Command {
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE})
messageSearchCmd := &cobra.Command{
Use: "search",
@@ -588,7 +588,7 @@ func newMailCommand() *cobra.Command {
},
})
folderCmd := &cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE}
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE})
folderListCmd := &cobra.Command{
Use: "list",
@@ -842,7 +842,7 @@ func newMailCommand() *cobra.Command {
folderCmd.AddCommand(folderListCmd, folderCreateCmd, folderDeleteCmd, folderUpdateCmd)
tagCmd := &cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE}
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE})
tagListCmd := &cobra.Command{
Use: "list",
@@ -995,7 +995,7 @@ func newMailCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagDeleteCmd, tagUpdateCmd)
threadCmd := &cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE}
threadCmd := newGroupCommand(&cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE})
threadListCmd := &cobra.Command{
Use: "list",
@@ -1975,7 +1975,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
},
})
attachmentCmd := &cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE})
attachmentListCmd := &cobra.Command{
Use: "list",
@@ -2443,7 +2443,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
sentMessageCmd := newGroupCommand(&cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE})
sentMessageRecallCmd := &cobra.Command{
Use: "recall",
@@ -2600,10 +2600,10 @@ internetMessageId 来源:message send / draft send / message reply / message r
_ = draftSendCmd.Flags().MarkHidden("sender")
draftSendCmd.Flags().String("id", "", "草稿邮件 ID (必填)")
draftCmd := &cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE}
draftCmd := newGroupCommand(&cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE})
draftCmd.AddCommand(draftCreateCmd, draftUpdateCmd, draftSendCmd)
userCmd := &cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE})
userSearchCmd := &cobra.Command{
Use: "search",
@@ -2707,7 +2707,7 @@ user 对象字段:
// ── template 子命令组 ──────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE})
templateCreateCmd := &cobra.Command{
Use: "create",
@@ -3057,7 +3057,7 @@ user 对象字段:
// ── contact 子命令组 ──────────────────────────────────
contactCmd := &cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE}
contactCmd := newGroupCommand(&cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE})
contactCreateCmd := &cobra.Command{
Use: "create",
@@ -3315,7 +3315,7 @@ user 对象字段:
contactCmd.AddCommand(contactCreateCmd, contactListCmd, contactUpdateCmd, contactBatchDeleteCmd)
// ── auto-reply 自动回复 ──────────────────────────────
autoReplyCmd := &cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE}
autoReplyCmd := newGroupCommand(&cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE})
autoReplyGetCmd := &cobra.Command{
Use: "get",
@@ -3393,7 +3393,7 @@ user 对象字段:
autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd)
// ── rule 收信规则 ────────────────────────────────────
ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}
ruleCmd := newGroupCommand(&cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE})
ruleListCmd := &cobra.Command{
Use: "list",
@@ -3578,7 +3578,7 @@ object 与 operation 合法组合:
ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd)
// ── allow-list 个人收信白名单 ────────────────────────────────
allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}
allowListCmd := newGroupCommand(&cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE})
allowListListCmd := &cobra.Command{
Use: "list",
@@ -3646,7 +3646,7 @@ object 与 operation 合法组合:
allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd)
// ── block-list 个人收信黑名单 ────────────────────────────────
blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}
blockListCmd := newGroupCommand(&cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE})
blockListListCmd := &cobra.Command{
Use: "list",
@@ -3713,7 +3713,7 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarCmd := newGroupCommand(&cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE})
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
@@ -3762,7 +3762,7 @@ object 与 operation 合法组合:
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventCmd := newGroupCommand(&cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE})
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
+2 -2
View File
@@ -46,12 +46,12 @@ func newMarkdownCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
})
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
+14 -14
View File
@@ -28,7 +28,7 @@ func newMinutesCommand() *cobra.Command {
},
},
})
minutesListCmd := &cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE}
minutesListCmd := newGroupCommand(&cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE})
minutesListMineCmd := &cobra.Command{
Use: "mine",
@@ -173,7 +173,7 @@ func newMinutesCommand() *cobra.Command {
},
})
minutesGetCmd := &cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE}
minutesGetCmd := newGroupCommand(&cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE})
minutesGetInfoCmd := &cobra.Command{
Use: "info",
@@ -544,7 +544,7 @@ func newMinutesCommand() *cobra.Command {
},
})
minutesUpdateCmd := &cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE}
minutesUpdateCmd := newGroupCommand(&cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE})
minutesUpdateTitleCmd := &cobra.Command{
Use: "title",
@@ -659,7 +659,7 @@ func newMinutesCommand() *cobra.Command {
// 单个工具通过 cmd 参数覆盖 create/pause/resume/end 四种指令。
const listeningNoteCmdTool = "执行听记指令-发起AI听记录音"
minutesRecordCmd := &cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE}
minutesRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE})
minutesRecordStartCmd := &cobra.Command{
Use: "start",
@@ -942,7 +942,7 @@ func newMinutesCommand() *cobra.Command {
minutesUpdateCmd.AddCommand(minutesUpdateTitleCmd, minutesUpdateSummaryCmd)
// ── mind-graph 子组 ─────────────────────────────────────────
mindGraphCmd := &cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE}
mindGraphCmd := newGroupCommand(&cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE})
mindGraphCreateCmd := &cobra.Command{
Use: "create",
@@ -1059,7 +1059,7 @@ func newMinutesCommand() *cobra.Command {
mindGraphCmd.AddCommand(mindGraphCreateCmd, mindGraphStatusCmd)
// ── speaker 子组 ────────────────────────────────────────────
speakerCmd := &cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE}
speakerCmd := newGroupCommand(&cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE})
speakerReplaceCmd := &cobra.Command{
Use: "replace",
@@ -1127,7 +1127,7 @@ func newMinutesCommand() *cobra.Command {
// 对应 MCP 工具 create_speaker_summary / get_speaker_summary
// 批量按听记维度汇总每位发言人的段落总结
speakerSummaryCmd := &cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE}
speakerSummaryCmd := newGroupCommand(&cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE})
speakerSummaryCreateCmd := &cobra.Command{
Use: "create",
@@ -1243,7 +1243,7 @@ func newMinutesCommand() *cobra.Command {
speakerCmd.AddCommand(speakerReplaceCmd, speakerSummaryCmd)
// ── hot-word 子组 ───────────────────────────────────────────
hotWordCmd := &cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE}
hotWordCmd := newGroupCommand(&cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE})
hotWordAddCmd := &cobra.Command{
Use: "add",
@@ -1462,7 +1462,7 @@ func newMinutesCommand() *cobra.Command {
// 完整流程:create → HTTP PUT → complete,或 create → cancel 取消。
// 注意:upload 子组的所有命令均使用 callMCPToolUnescaped 输出 JSON,
// 避免 presignedUrl 中的 & 被 Go 标准库转义为 \u0026。
uploadCmd := &cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE}
uploadCmd := newGroupCommand(&cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE})
// upload create — 对应 MCP 工具 create_upload_session
// 必填参数:fileName(--file-name), fileSize(--file-size)
@@ -1681,7 +1681,7 @@ func newMinutesCommand() *cobra.Command {
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
permissionCmd := newGroupCommand(&cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE})
// permission add — 对应 MCP 工具 add_member_permission
// 批量给多个听记增加成员,并设置成员的权限。
@@ -1951,7 +1951,7 @@ func newMinutesCommand() *cobra.Command {
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
// 对应 MCP 工具 query_user_tag_list / query_minutes_by_tag_id。
// 标签/分组由用户在听记页面手动创建,此处仅提供查询能力。
tagCmd := &cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE}
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE})
// tag list — 对应 MCP 工具 query_user_tag_list
// 无需传入参数,系统自动识别当前用户身份。
@@ -2075,7 +2075,7 @@ func newMinutesCommand() *cobra.Command {
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoCmd := newGroupCommand(&cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE})
audioMemoListCmd := &cobra.Command{
Use: "list",
@@ -2179,12 +2179,12 @@ func newMinutesCommand() *cobra.Command {
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
minutesCmd := newGroupCommand(&cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
})
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
+5 -5
View File
@@ -369,11 +369,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
attachmentCmd := newGroupCommand(&cobra.Command{
Use: "attachment",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
})
downloadURLCmd := NewLeafCommand(LeafSpec{
Use: "download-url",
@@ -724,14 +724,14 @@ func newOaCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "oa",
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
RunE: groupRunE,
}
})
approvalCmd := &cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE}
approvalCmd := newGroupCommand(&cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE})
approvalListPendingCmd := &cobra.Command{
Use: "list-pending",
+4 -4
View File
@@ -61,17 +61,17 @@ func newRecruitCommand() *cobra.Command {
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "recruit",
Short: "钉钉招聘",
Long: "查询和创建钉钉招聘中的职位信息。",
RunE: groupRunE,
}
job := &cobra.Command{
})
job := newGroupCommand(&cobra.Command{
Use: "job",
Short: "招聘职位管理",
RunE: groupRunE,
}
})
job.AddCommand(
newRecruitJobListCommand(),
newRecruitJobGetCommand(),
+6 -5
View File
@@ -64,7 +64,7 @@ func newReportCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "report",
Aliases: []string{"log"},
Short: "钉钉日志(OA 周报应用 / 日志模版填报)",
@@ -84,10 +84,10 @@ func newReportCommand() *cobra.Command {
别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`,
RunE: groupRunE,
}
})
// === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)===
templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE})
templateListCmd := &cobra.Command{
Use: "list",
@@ -179,7 +179,7 @@ func newReportCommand() *cobra.Command {
templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd)
// === entry subtree(单条日报操作 — get / stats / submit)===
entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}
entryCmd := newGroupCommand(&cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE})
entryGetCmd := &cobra.Command{
Use: "get",
@@ -386,9 +386,10 @@ func newReportCommand() *cobra.Command {
addReportListFlags(inboxListCmd)
inboxCmd.AddCommand(inboxListCmd)
newHybridGroupCommand(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",
+2 -81
View File
@@ -38,7 +38,7 @@ func newSheetCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newDeepGroupCommand(&cobra.Command{
Use: "sheet",
Short: "钉钉表格管理",
Long: `管理钉钉在线电子表格:创建表格、工作表管理、数据读写、单元格搜索、查找替换、单元格合并与取消合并、行列插入删除移动追加与属性更新、附件上传、浮动图片管理、筛选视图管理、下拉列表管理。
@@ -119,7 +119,7 @@ func newSheetCommand() *cobra.Command {
dws sheet template list 获取表格模板列表
dws sheet template search 搜索表格模板
dws sheet template apply 应用表格模板创建新表格文档`,
}
})
// ── Build commands via factory functions ──────────────────────────
workbookCmds := newWorkbookCmds()
@@ -296,15 +296,6 @@ func newSheetCommand() *cobra.Command {
attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint)
}
// Guards for grouped parent commands
attachUnknownSubcommandGuard(root)
attachUnknownSubcommandGuard(rangeCmd)
attachUnknownSubcommandGuard(filterCmd)
attachUnknownSubcommandGuard(filterViewCmd)
attachUnknownSubcommandGuard(condFormatCmd)
attachUnknownSubcommandGuard(chartCmd)
attachUnknownSubcommandGuard(pivotTableCmd)
return root
}
@@ -315,73 +306,3 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi
}
protectSheetMutationCommand(command, operation, targetHint)
}
// attachUnknownSubcommandGuard 为分组型命令挂上拼错子命令时的 did-you-mean 提示。
//
// 背景:cobra 对父命令的 Args 校验发生在 ParseFlags 之后,而 pflag 默认把未知 flag
// 当作硬错误抛出。于是 `dws sheet read --sheet-id X` 会先报 `unknown flag: --sheet-id`,
// 真正的根因(read 不是 sheet 的直接子命令)被彻底掩盖;同时 `dws sheet reead` 会被
// 当成位置参数静默吞掉、打印 help 后 exit=0,AI Agent 无法察觉命令执行失败。
//
// 本函数通过三件套让分组命令在"没匹配到子命令"时给出明确的错误与建议:
// 1. FParseErrWhitelist.UnknownFlags=true —— pflag 不再因未知 flag 中断,
// 未知 flag 连同其值一起被静默消化;
// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理;
// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子
// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra
// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。
//
// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段
// 精确匹配到的合法叶子命令。
func attachUnknownSubcommandGuard(cmd *cobra.Command) {
cmd.Args = cobra.ArbitraryArgs
cmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
cmd.SilenceUsage = true
// cobra 仅在 root 自动把 SuggestionsMinimumDistance 兑成 2,子命令默认为 0,
// 会导致 `sheet range reead` 这样的同级近似拼写无法触发内置建议。
if cmd.SuggestionsMinimumDistance <= 0 {
cmd.SuggestionsMinimumDistance = 2
}
cmd.RunE = func(c *cobra.Command, args []string) error {
if len(args) == 0 {
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)
}
}
fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath())
return fmt.Errorf("%s", buf.String())
}
}
// deepSuggestSubcommand 在所有后代命令里查找与 name 完全同名的可用子命令,
// 返回从 parent 出发的相对路径列表。用于把 `sheet read` 这样的平铺习惯引导到
// 真实的深路径 `sheet range read`。
func deepSuggestSubcommand(parent *cobra.Command, name string) []string {
var out []string
var walk func(c *cobra.Command, rel []string)
walk = func(c *cobra.Command, rel []string) {
for _, sub := range c.Commands() {
if !sub.IsAvailableCommand() {
continue
}
next := append(append([]string{}, rel...), sub.Name())
if sub.Name() == name {
out = append(out, strings.Join(next, " "))
}
walk(sub, next)
}
}
walk(parent, nil)
return out
}
+1 -1
View File
@@ -120,7 +120,7 @@ func validateChartProperties(props map[string]any) error {
// ─── Chart subcommands ──────────────────────────────────────────────────────
func newChartCmd() *cobra.Command {
chartCmd := &cobra.Command{Use: "chart", Short: "浮动图表管理"}
chartCmd := newDeepGroupCommand(&cobra.Command{Use: "chart", Short: "浮动图表管理"})
// ── chart list ──────────────────────────────────────────────────────
chartListCmd := &cobra.Command{
+2 -2
View File
@@ -7,12 +7,12 @@ import (
)
func newSheetCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "表格评论 / 单元格评论管理",
Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`,
RunE: groupRunE,
}
})
commentListCmd := &cobra.Command{
Use: "list",
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
func newCondFormatCmd() *cobra.Command {
condFormatCmd := &cobra.Command{Use: "cond-format", Short: "条件格式管理"}
condFormatCmd := newDeepGroupCommand(&cobra.Command{Use: "cond-format", Short: "条件格式管理"})
condFormatListCmd := &cobra.Command{
Use: "list",
+2 -2
View File
@@ -164,7 +164,7 @@ func runFilterViewGetCriteria(cmd *cobra.Command, _ []string) error {
// ── filter + filter-view 命令定义 ──────────────────────────────────────────────
func newFilterCmd() *cobra.Command {
filterCmd := &cobra.Command{Use: "filter", Short: "全局筛选管理"}
filterCmd := newDeepGroupCommand(&cobra.Command{Use: "filter", Short: "全局筛选管理"})
filterGetCmd := &cobra.Command{
Use: "get",
@@ -523,7 +523,7 @@ func newFilterCmd() *cobra.Command {
}
func newFilterViewCmd() *cobra.Command {
filterViewCmd := &cobra.Command{Use: "filter-view", Short: "筛选视图管理"}
filterViewCmd := newDeepGroupCommand(&cobra.Command{Use: "filter-view", Short: "筛选视图管理"})
filterViewListCmd := &cobra.Command{
Use: "list",
+1
View File
@@ -156,6 +156,7 @@ func newSheetImportCmdWithConfig(cfg importFlowConfig) *cobra.Command {
})
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importCmd.AddCommand(importCreateCmd, importGetCmd)
newHybridGroupCommand(importCmd)
return importCmd
}
+2 -3
View File
@@ -134,11 +134,11 @@ func readPivotProperties(raw string, requireValues bool) (map[string]any, error)
}
func newPivotTableCmd() *cobra.Command {
root := &cobra.Command{
root := newDeepGroupCommand(&cobra.Command{
Use: "pivot-table",
Short: "透视表管理",
RunE: groupRunE,
}
})
listCmd := &cobra.Command{
Use: "list",
@@ -370,6 +370,5 @@ func newPivotTableCmd() *cobra.Command {
deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd)
attachUnknownSubcommandGuard(root)
return root
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// (read/update/clear/sort/fill/copy-to/move-to).
// set-style, batch-set-style, and batch-clear are added by newSheetCommand().
func newRangeCmd() *cobra.Command {
rangeCmd := &cobra.Command{Use: "range", Short: "数据区域操作"}
rangeCmd := newDeepGroupCommand(&cobra.Command{Use: "range", Short: "数据区域操作"})
rangeReadCmd := &cobra.Command{
Use: "read",
+2 -4
View File
@@ -9,11 +9,11 @@ import (
// newSheetTemplateCmd builds the `dws sheet template` command group.
func newSheetTemplateCmd() *cobra.Command {
templateCmd := &cobra.Command{
templateCmd := newDeepGroupCommand(&cobra.Command{
Use: "template",
Short: "表格模板管理",
RunE: groupRunE,
}
})
templateListCmd := &cobra.Command{
Use: "list",
@@ -205,7 +205,5 @@ func newSheetTemplateCmd() *cobra.Command {
RegisterCrossProductAliases(child)
}
attachUnknownSubcommandGuard(templateCmd)
return templateCmd
}
+2 -2
View File
@@ -8,12 +8,12 @@ import (
)
func newSheetVersionCmd() *cobra.Command {
versionCmd := &cobra.Command{
versionCmd := newGroupCommand(&cobra.Command{
Use: "version",
Short: "表格历史版本管理",
Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
})
versionSaveCmd := &cobra.Command{
Use: "save",
@@ -4,10 +4,13 @@ import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"time"
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"
)
@@ -20,9 +23,37 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T
group := &cobra.Command{Use: "range", Run: func(*cobra.Command, []string) {}}
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 := cmdutil.NewCommandResolution(
root,
"read",
cmdutil.ResolutionUnknownSubcommand,
cmdutil.SuggestDescendantSubcommands(root, "read"),
"",
).Err()
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)
}
bounded := &cobra.Command{Use: "sheet"}
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
group := &cobra.Command{Use: name}
group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
bounded.AddCommand(group)
}
err = cmdutil.NewCommandResolution(
bounded,
"read",
cmdutil.ResolutionUnknownSubcommand,
cmdutil.SuggestDescendantSubcommands(bounded, "read"),
"",
).Err()
if !errors.As(err, &structured) {
t.Fatalf("bounded deep suggestion error = %T %v", err, err)
}
suggestions, ok := structured.Details["suggestions"].([]string)
if !ok || !slices.Equal(suggestions, []string{"alpha read", "beta read", "delta read"}) {
t.Fatalf("bounded deep suggestions = %#v", structured.Details["suggestions"])
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
+5 -5
View File
@@ -69,14 +69,14 @@ func newTodoCommand() *cobra.Command {
},
},
})
todoCmd := &cobra.Command{
todoCmd := newGroupCommand(&cobra.Command{
Use: "todo",
Short: "待办任务管理",
Long: `管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。`,
RunE: groupRunE,
}
})
todoTaskCmd := &cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE}
todoTaskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE})
todoTaskCreateCmd := &cobra.Command{
Use: "create",
@@ -1303,7 +1303,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
// dws todo comment — 待办评论
// 对应 MCP:add_todo_comment / list_todo_comment / delete_todo_comment
// ──────────────────────────────────────────────────────────
todoCommentCmd := &cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE}
todoCommentCmd := newGroupCommand(&cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE})
todoCommentAddCmd := &cobra.Command{
Use: "add",
@@ -1474,7 +1474,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
// dws todo tag — 待办标签
// 对应 MCP:tag_todo / delete_todo_tag / update_todo_tag / list_todo_tags / create_todo_tag
// ──────────────────────────────────────────────────────────
todoTagCmd := &cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE}
todoTagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE})
todoTagAddCmd := &cobra.Command{
Use: "add",
+2 -2
View File
@@ -45,7 +45,7 @@ func newWhiteboardCommand() *cobra.Command {
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
@@ -53,7 +53,7 @@ func newWhiteboardCommand() *cobra.Command {
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
})
queryCmd := &cobra.Command{
Use: "query",
+11 -11
View File
@@ -160,14 +160,14 @@ func newWikiCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "wiki",
Short: "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)、动态查询(知识库活动动态)。`,
RunE: groupRunE,
}
})
spaceCmd := &cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE}
spaceCmd := newGroupCommand(&cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE})
spaceCreateCmd := &cobra.Command{
Use: "create",
@@ -519,12 +519,12 @@ func newWikiCommand() *cobra.Command {
spaceCmd.AddCommand(spaceCreateCmd, spaceGetCmd, spaceListCmd, spaceSearchCmd, spaceDeleteCmd)
// ── member (知识库成员管理) ───────────────────────────────
memberCmd := &cobra.Command{
memberCmd := newGroupCommand(&cobra.Command{
Use: "member",
Short: "知识库成员管理",
Long: `管理钉钉知识库的成员:添加成员、更新成员权限、查询成员列表、移除成员。`,
RunE: groupRunE,
}
})
memberAddCmd := &cobra.Command{
Use: "add",
@@ -954,12 +954,12 @@ ORG 类型授权不会出现在查询结果中。`,
// ── node (知识库节点管理) ─────────────────────────────────
// 对齐飞书 cli-lark wiki node 设计:内建 list/create/copy/move/delete,
// 一跳直达 doc MCP server,不再经过 proxy chain。
nodeCmd := &cobra.Command{
nodeCmd := newGroupCommand(&cobra.Command{
Use: "node",
Short: "知识库节点管理",
Long: `管理钉钉知识库中的节点(文档/文件夹/表格等):列出、创建、复制、移动、删除。`,
RunE: groupRunE,
}
})
nodeListCmd := &cobra.Command{
Use: "list",
@@ -1415,12 +1415,12 @@ ORG 类型授权不会出现在查询结果中。`,
root.AddCommand(nodeCmd)
// ── feed (知识库动态查询) ─────────────────────────────────
feedCmd := &cobra.Command{
feedCmd := newGroupCommand(&cobra.Command{
Use: "feed",
Short: "知识库动态查询",
Long: `查询知识库的动态:谁在什么时间更新/上传/评论了哪些文档。`,
RunE: groupRunE,
}
})
feedListCmd := &cobra.Command{
Use: "list",
@@ -1536,14 +1536,14 @@ ORG 类型授权不会出现在查询结果中。`,
)
// ── [PROXY] wiki file/doc * → doc * (兼容旧路径) ──
fileGroup := &cobra.Command{Use: "file", Hidden: true, RunE: groupRunE}
fileGroup := newGroupCommand(&cobra.Command{Use: "file", Hidden: true, RunE: groupRunE})
fileGroup.AddCommand(
proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki file list → doc list
proxySubCmd("search", "doc", "search", map[string]string{"workspace": "workspace-ids"}), // [PROXY] wiki file search → doc search
)
root.AddCommand(fileGroup)
docGroup := &cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE}
docGroup := newGroupCommand(&cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE})
docGroup.AddCommand(
proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki doc list → doc list
proxySubCmd("read", "doc", "read", nil), // [PROXY] wiki doc read → doc read
+6 -2
View File
@@ -19,8 +19,8 @@ import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -87,8 +87,12 @@ DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
它不参与 claw-type 派生,也不参与 host-owned PAT 判定。
DWS_CHANNEL 只用于上游 channelCode。`,
RunE: cmdutil.GroupRunE,
}
corecmd.ApplyGroupPolicy(patCmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
patCmd.AddCommand(newChmodCommand(c))
patCmd.AddCommand(newBrowserPolicyCommand())
+2 -1
View File
@@ -6,12 +6,13 @@ package pat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"}
shortcutGroup := cobracmd.NewGroupCommand("pat", "pat shortcuts")
shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"})
root.AddCommand(&cobra.Command{Use: "aaa-other"})
root.AddCommand(shortcutGroup)
+110 -7
View File
@@ -32,10 +32,10 @@ import (
// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
// values.
//
// Explicit +shortcut tokens and annotated group subcommands are validated
// before flag parsing so an unknown command cannot be misreported as an
// unknown flag on its nearest parent. Other unresolved paths remain Cobra's
// responsibility.
// Explicit +shortcut tokens and groups whose typed GroupPolicy enables
// recovery are validated before flag parsing so an unknown command cannot be
// misreported as an unknown flag on its nearest parent. Other unresolved paths
// remain Cobra's responsibility.
func RunPreParse(root *cobra.Command, engine *Engine) error {
_, err := RunPreParseArgs(root, engine, os.Args[1:])
return err
@@ -64,7 +64,7 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
// the unrelated root command `event list`. Remove only known root-persistent
// flags from the traversal copy; the original argv remains intact for the
// handlers and Cobra's real parse.
target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs))
target, remaining, err := root.Traverse(argsForCommandTraversalForEngine(root, engine, normalizedArgs))
if err != nil {
return nil, nil
}
@@ -129,11 +129,20 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
}
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
args, _ := argsForCommandTraversalWithPositions(root, rawArgs)
args, _ := argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs)
return args
}
func argsForCommandTraversalForEngine(root *cobra.Command, engine *Engine, rawArgs []string) []string {
args, _ := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs)
return args
}
func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) {
return argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs)
}
func argsForCommandTraversalWithPositionsForEngine(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, []int) {
if root == nil || len(rawArgs) == 0 {
positions := make([]int, len(rawArgs))
for index := range positions {
@@ -153,6 +162,14 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string)
matcher := newFlagTokenMatcher(flags)
out := make([]string, 0, len(rawArgs))
positions := make([]int, 0, len(rawArgs))
target := root
commandPathOpen := true
unresolvedCommandToken := ""
unresolvedCommandPosition := -1
commandTokens := make([]string, 0, len(rawArgs))
commandPositions := make([]int, 0, len(rawArgs))
commandFlagMatchers := make(map[*cobra.Command]*flagTokenMatcher)
commandFlagValueIndex := -1
for index := 0; index < len(rawArgs); index++ {
argument := rawArgs[index]
if argument == "--" {
@@ -162,12 +179,86 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string)
}
break
}
if index == commandFlagValueIndex {
out = append(out, argument)
positions = append(positions, index)
commandFlagValueIndex = -1
continue
}
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
if !matched {
out = append(out, argument)
positions = append(positions, index)
if commandPathOpen {
if strings.HasPrefix(argument, "-") {
commandMatcher := commandFlagMatchers[target]
if commandMatcher == nil {
commandMatcher = newFlagTokenMatcher(target.Flags(), target.InheritedFlags())
commandFlagMatchers[target] = commandMatcher
}
commandFlag, commandInlineValue, commandMatched := commandMatcher.matchTraversalToken(argument)
if commandMatched {
if index+1 < len(rawArgs) {
if _, ok := separatedBoolValue(argument, rawArgs[index+1], commandFlag, commandInlineValue); ok ||
(!commandInlineValue && commandFlag.NoOptDefVal == "") {
commandFlagValueIndex = index + 1
}
}
continue
}
}
child := exactTraversalChild(target, argument)
if child == nil {
commandPathOpen = false
if !strings.HasPrefix(argument, "-") {
unresolvedCommandToken = argument
unresolvedCommandPosition = index
}
} else {
target = child
commandTokens = append(commandTokens, argument)
commandPositions = append(commandPositions, index)
}
}
continue
}
// Fuzzy matching is useful for a misspelled root boolean before a
// command (`--dry-rnu calendar ...`), but after a product has already
// resolved the same spelling may be an unknown local value flag. For
// example, `aisearch --types value` is deliberately protected from
// alias correction, while root traversal can otherwise fuzz `--types`
// to the global boolean `--yes` and expose `value` as a fake command.
// Preserve the token when the owning command's reviewed semantic table
// protects that spelling. Protection wins even for detached boolean
// literals and inline values: treating `--types false` as fuzzy
// `--yes false` would otherwise bypass the reviewed parent guard. An
// ordinary unprotected fuzzy root boolean such as `group --yess child`
// remains traversable.
if strings.HasPrefix(argument, "--") && flag != nil && flag.NoOptDefVal != "" &&
matcher.matchLongToken(argument).fuzzy && target != root {
name := strings.TrimPrefix(argument, "--")
if separator := strings.IndexByte(name, '='); separator >= 0 {
name = name[:separator]
}
_, protected := engine.resolveFlagProtection(target.CommandPath(), cmdutil.Morph(name))
if protected {
// Freeze traversal at the exact command prefix. Passing the
// protected unknown flag to Cobra can itself fail (notably when
// its value is a boolean literal), while retaining earlier local
// flags can let their values masquerade as children. The synthetic
// terminator affects only this traversal copy; handlers and Cobra's
// real parse still receive the original argv.
out = append(out[:0], commandTokens...)
positions = append(positions[:0], commandPositions...)
if unresolvedCommandToken != "" {
out = append(out, unresolvedCommandToken)
positions = append(positions, unresolvedCommandPosition)
}
out = append(out, "--")
positions = append(positions, index)
break
}
}
if index+1 < len(rawArgs) {
if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok {
index++
@@ -181,6 +272,15 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string)
return out, positions
}
func exactTraversalChild(parent *cobra.Command, token string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == token || child.HasAlias(token) {
return child
}
}
return nil
}
// separatedBoolValue recognises model-friendly `--flag false` and exact
// shorthand `-f false` spellings without mistaking an already attached value
// or a shorthand cluster for a detached value. pflag otherwise treats a bare
@@ -204,6 +304,7 @@ type longFlagMatch struct {
value string
hasValue bool
recognized bool
fuzzy bool
}
type flagTokenMatcher struct {
@@ -253,6 +354,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true}
}
fuzzyMatch := false
if normalized, ok := NormalizeFlagToken(argument, m.known); ok {
canonical = normalized
} else if split, ok := SplitStickyFlag(argument, m.specByName); ok {
@@ -260,6 +362,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true}
} else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok {
canonical = fuzzy
fuzzyMatch = true
} else {
return longFlagMatch{}
}
@@ -267,7 +370,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
body = strings.TrimPrefix(canonical, "--")
name, value, hasValue = strings.Cut(body, "=")
flag := m.byName[name]
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil}
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil, fuzzy: fuzzyMatch}
}
func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) {
+1 -1
View File
@@ -24,7 +24,7 @@ func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs [
if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 {
return rawArgs, nil, nil
}
traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs)
traversalArgs, positions := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs)
entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs)
if !ok {
return rawArgs, nil, nil
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
@@ -40,6 +41,24 @@ func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags(
}
}
func TestCrossPlatformCoverageCommandFallbackPrecedesLaterProtectedFlag(t *testing.T) {
root, _ := commandFallbackPipelineRoot()
root.PersistentFlags().Bool("yes", false, "")
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
})
engine.Register(reviewedProtectionTestHandler{})
raw := []string{"chat", "+bad", "--types", "value"}
ctx, err := RunPreParseArgs(root, engine, raw)
if err != nil {
t.Fatal(err)
}
want := []string{"chat", "+good", "--types", "value"}
if ctx == nil || ctx.Command != "dws chat +good" || !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("fallback before protected flag = %#v, want args=%v", ctx, want)
}
}
func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) {
root, executed := commandFallbackPipelineRoot()
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
@@ -346,6 +365,21 @@ func commandFallbackPipelineRoot() (*cobra.Command, *string) {
group := &cobra.Command{Use: "group"}
group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search"))
chat.AddCommand(good, other, search, group)
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
corecmd.ApplyGroupPolicy(chat, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
root.AddCommand(chat)
return root, executed
}
+22 -112
View File
@@ -4,28 +4,21 @@
package pipeline
import (
"fmt"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"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:
// - a top-level service followed by an explicit +shortcut token; and
// - a command explicitly annotated as a group container.
//
// Other unresolved positionals remain Cobra's responsibility so commands that
// legitimately accept positional arguments are not reclassified.
// Only groups whose typed framework policy rejects positionals and enables
// recovery are handled here. Commands that legitimately accept positionals
// remain Cobra's responsibility, without inference from tree shape or RunE.
func validateUnresolvedCommand(target *cobra.Command, remaining []string) error {
if target == nil || len(remaining) == 0 {
return nil
@@ -34,110 +27,27 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
return nil
}
if isExplicitShortcutCandidate(target, candidate) {
return unknownShortcutError(target, candidate)
}
if cmdutil.IsGroup(target) {
return unknownSubcommandError(target, candidate)
}
return nil
}
func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
if target == nil || !strings.HasPrefix(candidate, "+") {
return false
}
root := target.Root()
if root == nil || target == root || target.Parent() != root {
return false
}
for _, child := range target.Commands() {
if strings.HasPrefix(child.Name(), "+") {
return true
}
}
return false
}
func unknownShortcutError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
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),
)
}
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
return apperrors.NewValidation(
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
apperrors.WithReason("unknown_subcommand"),
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
apperrors.WithActions(action),
)
}
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, ", ") + "?"
}
}
// 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 {
// 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
}
minimumDistance := parent.SuggestionsMinimumDistance
if minimumDistance <= 0 {
minimumDistance = 2
policy, declared, err := corecmd.GroupPolicyFor(target)
if err != nil {
return err
}
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)
if !declared || policy.Positionals != corecmd.PositionalsReject || policy.Recovery == corecmd.RecoveryDisabled {
return nil
}
reason := cmdutil.ClassifyCommandResolution(target, candidate)
suggestions := cmdutil.SuggestSubcommands(target, candidate)
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == corecmd.RecoveryDeep {
if deep := cmdutil.SuggestDescendantSubcommands(target, candidate); len(deep) > 0 {
suggestions = deep
}
}
sort.Strings(suggestions)
return suggestions
// ValidateGroupPolicy and the guard above leave only sibling/deep recovery.
return cmdutil.NewCommandResolution(target, candidate, reason, suggestions, "").Err()
}
+293 -19
View File
@@ -5,14 +5,45 @@ package pipeline
import (
stderrors "errors"
"slices"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
type reviewedProtectionTestHandler struct{}
func (reviewedProtectionTestHandler) Name() string { return "reviewed-protection-test" }
func (reviewedProtectionTestHandler) Phase() Phase { return PreParse }
func (reviewedProtectionTestHandler) ResolveFlagProtection(command, flag string) (FlagProtection, bool) {
if (command == "dws aisearch" || command == "dws chat") && flag == "types" {
return FlagProtectionBlocked, true
}
return "", false
}
func (reviewedProtectionTestHandler) Handle(ctx *Context) error {
if ctx.Command == "dws aisearch" || ctx.Command == "dws chat" {
ctx.ProtectFlag("types", FlagProtectionBlocked)
}
if ctx.Command == "dws aisearch enterprise" {
for index, argument := range ctx.Args {
if argument != "--leaf-typz" {
continue
}
ctx.Args[index] = "--leaf-type"
ctx.AddCorrection("reviewed-protection-test", PreParse, "leaf-type", argument, "--leaf-type", "test")
}
}
return nil
}
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) {
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
@@ -21,6 +52,7 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF
&cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}},
)
applyTestNavigationGroup(chat)
root.AddCommand(chat)
err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"})
@@ -28,22 +60,29 @@ 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 {
t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags)
}
if structured.Details["input"] != "+chat-mesages" {
t.Fatalf("Details.input = %#v", structured.Details["input"])
}
if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 1 || suggestions[0] != "+chat-messages" {
t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"])
}
}
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) {
root := &cobra.Command{Use: "dws"}
dev := &cobra.Command{Use: "dev"}
app := &cobra.Command{Use: "app"}
cmdutil.MarkGroup(app)
applyTestNavigationGroup(app)
app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
dev.AddCommand(app)
root.AddCommand(dev)
@@ -53,21 +92,37 @@ 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)
}
if structured.Details["input"] != "search" {
t.Fatalf("Details.input = %#v", structured.Details["input"])
}
if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 0 {
t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"])
}
legacy := &cobra.Command{Use: "legacy", RunE: cmdutil.GroupRunE}
applyTestNavigationGroup(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 {
@@ -77,11 +132,25 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon
}
}
func TestCrossPlatformCoverageValidateUnresolvedCommandRejectsInvalidGroupPolicyMetadata(t *testing.T) {
group := &cobra.Command{Use: "broken"}
applyTestNavigationGroup(group)
for key := range group.Annotations {
group.Annotations[key] = "navigation_only|reject|unexpected"
}
err := validateUnresolvedCommand(group, []string{"typo"})
if err == nil || !strings.Contains(err.Error(), "invalid group recovery policy") {
t.Fatalf("validateUnresolvedCommand() error = %v", err)
}
}
func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
chat := &cobra.Command{Use: "chat"}
chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
applyTestNavigationGroup(chat)
root.AddCommand(chat)
ctx, err := RunPreParseArgs(root, nil, []string{
@@ -98,27 +167,164 @@ func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAnd
validRoot := &cobra.Command{Use: "dws"}
validChat := &cobra.Command{Use: "chat"}
validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
applyTestNavigationGroup(validChat)
validRoot.AddCommand(validChat)
if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil {
t.Fatalf("valid shortcut = %#v, %v", ctx, err)
}
}
func TestCrossPlatformCoverageRunPreParseArgsKeepsUnknownFlagValuesOutOfCommandResolution(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
aisearch := &cobra.Command{Use: "aisearch", RunE: func(*cobra.Command, []string) error { return nil }}
aisearch.Flags().String("query", "", "")
aisearch.AddCommand(&cobra.Command{Use: "enterprise", Run: func(*cobra.Command, []string) {}})
corecmd.ApplyGroupPolicy(aisearch, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
root.AddCommand(aisearch)
engine := NewEngine()
engine.Register(reviewedProtectionTestHandler{})
frozenParentTraversal := []string{"aisearch", "--"}
if got := argsForCommandTraversal(root, []string{"aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) {
t.Fatalf("default command traversal = %v", got)
}
guarded := []string{"aisearch", "--types", "FIXTURE_VALUE"}
if got := argsForCommandTraversalForEngine(root, engine, guarded); !slices.Equal(got, frozenParentTraversal) {
t.Fatalf("guarded flag traversal = %v, want %v", got, frozenParentTraversal)
}
if ctx, err := RunPreParseArgs(root, engine, guarded); err != nil || ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") {
t.Fatalf("guarded flag value entered command resolution: context=%#v error=%v", ctx, err)
}
// A protected flag value can also be spelled exactly like a child. Command
// traversal must not guess which meaning the user intended.
guardedChildValue := []string{"aisearch", "--types", "enterprise"}
if got := argsForCommandTraversalForEngine(root, engine, guardedChildValue); !slices.Equal(got, frozenParentTraversal) {
t.Fatalf("guarded child-name value traversal = %v, want %v", got, frozenParentTraversal)
}
if ctx, err := RunPreParseArgs(root, engine, guardedChildValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" {
t.Fatalf("guarded child-name value entered command resolution: context=%#v error=%v", ctx, err)
}
for _, guardedBooleanValue := range [][]string{
{"aisearch", "--types", "false", "enterprise"},
{"aisearch", "--types=false", "enterprise"},
} {
if got := argsForCommandTraversalForEngine(root, engine, guardedBooleanValue); !slices.Equal(got, frozenParentTraversal) {
t.Fatalf("guarded boolean-like value traversal = %v, want %v", got, frozenParentTraversal)
}
if ctx, err := RunPreParseArgs(root, engine, guardedBooleanValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" {
t.Fatalf("guarded boolean-like value selected wrong command: context=%#v error=%v", ctx, err)
}
}
guardedAfterLocalValue := []string{"aisearch", "--unknown-query", "Alice", "--types", "enterprise"}
if got := argsForCommandTraversalForEngine(root, engine, guardedAfterLocalValue); !slices.Equal(got, frozenParentTraversal) {
t.Fatalf("guarded flag after local value traversal = %v, want %v", got, frozenParentTraversal)
}
if ctx, err := RunPreParseArgs(root, engine, guardedAfterLocalValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" {
t.Fatalf("guarded flag after local value selected wrong command: context=%#v error=%v", ctx, err)
}
legitimateParentFlag := []string{"aisearch", "--query", "Alice", "enterprise", "--types", "document", "--leaf-typz", "value"}
if ctx, err := RunPreParseArgs(root, engine, legitimateParentFlag); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" ||
!slices.Contains(ctx.Args, "--leaf-type") {
t.Fatalf("parent flag/value prevented leaf resolution: context=%#v error=%v", ctx, err)
}
// Leading fuzzy root flags remain useful because no command path is
// ambiguous yet. Once a group has resolved, the same fuzzy spelling is
// preserved for the semantic flag pipeline instead of being consumed here.
if got := argsForCommandTraversalForEngine(root, engine, []string{"--yess", "aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) {
t.Fatalf("leading fuzzy boolean traversal = %v", got)
}
if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) {
t.Fatalf("fuzzy boolean after group traversal = %v", got)
}
if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "false", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) {
t.Fatalf("fuzzy boolean value before child traversal = %v", got)
}
leafArgs := []string{"aisearch", "--yess", "enterprise", "--leaf-typz", "value"}
if ctx, err := RunPreParseArgs(root, engine, leafArgs); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" ||
!slices.Contains(ctx.Args, "--leaf-type") {
t.Fatalf("fuzzy root flag skipped leaf handler: context=%#v error=%v", ctx, err)
}
_, err := RunPreParseArgs(root, engine, []string{"aisearch", "--yes", "enterprize"})
requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand))
_, err = RunPreParseArgs(root, engine, []string{"aisearch", "enterprize", "--types", "enterprise"})
structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand))
if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) {
t.Fatalf("typo before protected flag suggestions = %#v", structured.Details["suggestions"])
}
_, err = RunPreParseArgs(root, engine, []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise"})
structured = requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand))
if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) {
t.Fatalf("typo after parent flag suggestions = %#v", structured.Details["suggestions"])
}
}
func TestCrossPlatformCoverageRunPreParseArgsUsesTypedDeepRecoveryPolicy(t *testing.T) {
root := &cobra.Command{Use: "dws"}
sheet := &cobra.Command{Use: "sheet"}
rangeGroup := &cobra.Command{Use: "range"}
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
corecmd.ApplyGroupPolicy(rangeGroup, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
sheet.AddCommand(
rangeGroup,
&cobra.Command{Use: "ready", Run: func(*cobra.Command, []string) {}},
)
corecmd.ApplyGroupPolicy(sheet, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(sheet)
ctx, err := RunPreParseArgs(root, nil, []string{"sheet", "read", "--sheet-id", "sheet-1"})
if ctx == nil || ctx.Command != "dws sheet" {
t.Fatalf("Context = %#v, want unresolved target dws sheet", ctx)
}
structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand))
if structured.Message != `unknown subcommand "read" for "dws sheet"` {
t.Fatalf("Message = %q", structured.Message)
}
if structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
t.Fatalf("Hint = %q", structured.Hint)
}
if !slices.Equal(structured.Actions, []string{"Run 'dws sheet --help' for the full list"}) {
t.Fatalf("Actions = %#v", structured.Actions)
}
if structured.Details["input"] != "read" {
t.Fatalf("Details.input = %#v", structured.Details["input"])
}
suggestions, ok := structured.Details["suggestions"].([]string)
if !ok || !slices.Equal(suggestions, []string{"range read"}) {
t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"])
}
}
func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) {
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)
}
}
@@ -130,14 +336,21 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat
service.AddCommand(group)
root.AddCommand(service)
if isExplicitShortcutCandidate(root, "+missing") {
if got := cmdutil.ClassifyCommandResolution(root, "+missing"); got != cmdutil.ResolutionUnknownSubcommand {
t.Fatal("root command was treated as a shortcut service")
}
if isExplicitShortcutCandidate(group, "+missing") {
if got := cmdutil.ClassifyCommandResolution(group, "+missing"); got != cmdutil.ResolutionUnknownSubcommand {
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)
service.AddCommand(&cobra.Command{Use: "+known", Aliases: []string{"+alias"}, Run: func(*cobra.Command, []string) {}})
if got := cmdutil.ClassifyCommandResolution(service, "+missing"); got != cmdutil.ResolutionUnknownShortcut {
t.Fatalf("top-level shortcut typo reason = %q", got)
}
if got := cmdutil.ClassifyCommandResolution(service, "+known"); got != cmdutil.ResolutionUnknownSubcommand {
t.Fatalf("exact shortcut child was reclassified as unresolved shortcut: %q", got)
}
if got := cmdutil.SuggestSubcommands(nil, "missing"); got != nil {
t.Fatalf("SuggestSubcommands(nil) = %#v", got)
}
service.SuggestionsMinimumDistance = 1
@@ -146,11 +359,64 @@ 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 := cmdutil.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 TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing.T) {
standaloneHint := cmdutil.HintSubCmd("send", " \t ")
structured := requireCommandResolutionError(t, standaloneHint.RunE(standaloneHint, nil), "unknown_subcommand")
if structured.Hint != "Run 'send --help' for the full list" {
t.Fatalf("standalone empty hint = %q", structured.Hint)
}
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo"}
group.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
root.AddCommand(group)
structured = requireCommandResolutionError(t, cmdutil.GroupRunE(group, []string{"lisst"}), "unknown_subcommand")
if structured.Details["input"] != "lisst" {
t.Fatalf("GroupRunE() details.input = %#v", structured.Details["input"])
}
suggestions, ok := structured.Details["suggestions"].([]string)
if !ok || len(suggestions) != 1 || suggestions[0] != "list" {
t.Fatalf("GroupRunE() details.suggestions = %#v", structured.Details["suggestions"])
}
var help strings.Builder
group.SetOut(&help)
if err := cmdutil.GroupRunE(group, nil); err != nil {
t.Fatalf("GroupRunE() help error = %v", err)
}
if output := help.String(); !strings.Contains(output, "Usage:") || !strings.Contains(output, "list") {
t.Fatalf("GroupRunE() help output = %q", output)
}
}
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
t.Helper()
var structured *apperrors.Error
@@ -162,3 +428,11 @@ func requireCommandResolutionError(t *testing.T, err error, reason string) *appe
}
return structured
}
func applyTestNavigationGroup(command *cobra.Command) {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
}
+16
View File
@@ -106,6 +106,22 @@ func (e *Engine) HasHandlers(phase Phase) bool {
return len(e.handlers[phase]) > 0
}
func (e *Engine) resolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool) {
if e == nil || rawCommandPath == "" || morphedFlag == "" {
return "", false
}
for _, handler := range e.handlers[PreParse] {
resolver, ok := handler.(FlagProtectionResolver)
if !ok {
continue
}
if protection, protected := resolver.ResolveFlagProtection(rawCommandPath, morphedFlag); protected {
return protection, true
}
}
return "", false
}
// RunPhase executes all handlers registered for the given phase in
// chain order. If any handler returns an error, execution stops
// immediately and the error is returned with the handler name as
+49
View File
@@ -30,6 +30,20 @@ type stubHandler struct {
callSeq *[]string
}
type stubProtectionResolver struct {
*stubHandler
command string
flag string
protection FlagProtection
}
func (h *stubProtectionResolver) ResolveFlagProtection(command, flag string) (FlagProtection, bool) {
if command == h.command && flag == h.flag {
return h.protection, true
}
return "", false
}
func (h *stubHandler) Name() string { return h.name }
func (h *stubHandler) Phase() Phase { return h.phase }
func (h *stubHandler) Handle(ctx *Context) error {
@@ -79,6 +93,41 @@ func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testi
}
}
func TestCrossPlatformCoverageEngineResolvesReviewedFlagProtection(t *testing.T) {
var nilEngine *Engine
if protection, ok := nilEngine.resolveFlagProtection("dws demo", "types"); ok || protection != "" {
t.Fatalf("nil engine protection = %q, %t", protection, ok)
}
engine := NewEngine()
engine.Register(newStub("ordinary", PreParse, nil))
engine.Register(&stubProtectionResolver{
stubHandler: newStub("protection", PreParse, nil),
command: "dws demo",
flag: "types",
protection: FlagProtectionBlocked,
})
for _, test := range []struct {
name string
command string
flag string
want FlagProtection
protected bool
}{
{name: "empty command", flag: "types"},
{name: "empty flag", command: "dws demo"},
{name: "unreviewed", command: "dws demo", flag: "other"},
{name: "protected", command: "dws demo", flag: "types", want: FlagProtectionBlocked, protected: true},
} {
t.Run(test.name, func(t *testing.T) {
got, ok := engine.resolveFlagProtection(test.command, test.flag)
if got != test.want || ok != test.protected {
t.Fatalf("resolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected)
}
})
}
}
func TestRegisterAndHandlerCount(t *testing.T) {
e := NewEngine()
e.Register(newStub("a", PreParse, nil))
+9
View File
@@ -35,3 +35,12 @@ type Handler interface {
// the chain, or nil to continue.
Handle(ctx *Context) error
}
// FlagProtectionResolver exposes reviewed blocked/ambiguous flag names to
// command traversal. A PreParse handler that owns those decisions implements
// this optional interface so traversal and the handler chain use one semantic
// source instead of independently guessing whether an unknown flag may carry a
// value.
type FlagProtectionResolver interface {
ResolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool)
}
@@ -41,6 +41,31 @@ type SemanticAliasHandler struct {
func (SemanticAliasHandler) Name() string { return "semantic-alias" }
func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse }
// ResolveFlagProtection lets command traversal consult the same reviewed
// blocked/ambiguous table used by Handle. This is intentionally a read-only
// projection: the handler remains responsible for recording the decision on
// the final Context.
func (h SemanticAliasHandler) ResolveFlagProtection(rawCommandPath, morphedFlag string) (pipeline.FlagProtection, bool) {
if h.Lookup == nil || rawCommandPath == "" || morphedFlag == "" {
return "", false
}
_, blocked, ambiguous, ok := h.Lookup(rawCommandPath)
if !ok {
return "", false
}
for _, name := range ambiguous {
if name == morphedFlag {
return pipeline.FlagProtectionAmbiguous, true
}
}
for _, name := range blocked {
if name == morphedFlag {
return pipeline.FlagProtectionBlocked, true
}
}
return "", false
}
func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error {
if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 {
return nil
@@ -62,6 +62,34 @@ func TestSemanticAliasHandlerNameAndPhase(t *testing.T) {
}
}
func TestCrossPlatformCoverageSemanticAliasHandlerResolvesReviewedProtection(t *testing.T) {
h := newSemanticHandler()
if protection, ok := (SemanticAliasHandler{}).ResolveFlagProtection("dws demo cmd", "count"); ok || protection != "" {
t.Fatalf("nil lookup protection = %q, %t", protection, ok)
}
for _, test := range []struct {
name string
command string
flag string
want pipeline.FlagProtection
protected bool
}{
{name: "empty command", flag: "count"},
{name: "empty flag", command: "dws demo cmd"},
{name: "blocked", command: "dws demo cmd", flag: "count", want: pipeline.FlagProtectionBlocked, protected: true},
{name: "ambiguous", command: "dws demo cmd", flag: "user-id", want: pipeline.FlagProtectionAmbiguous, protected: true},
{name: "unreviewed", command: "dws demo cmd", flag: "missing"},
{name: "other command", command: "dws other", flag: "count"},
} {
t.Run(test.name, func(t *testing.T) {
got, ok := h.ResolveFlagProtection(test.command, test.flag)
if got != test.want || ok != test.protected {
t.Fatalf("ResolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected)
}
})
}
}
func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}}
if err := newSemanticHandler().Handle(ctx); err != nil {
+750
View File
@@ -0,0 +1,750 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public
// surface intentionally stays smaller than the atomic helper surface: only
// reads with strict response contracts and safe live proof are available.
package agoal
import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"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/internal/shortcut"
)
const productAgoal = "agoal"
var agoalLoadLocation = time.LoadLocation
func agoalResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空")
}
rawSuccess, present := data["success"]
if !present {
return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态")
}
success, ok := rawSuccess.(bool)
if !ok {
return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !success {
message, _ := data["message"].(string)
if strings.TrimSpace(message) == "" {
message = "服务明确返回 success=false"
}
return nil, agoalResponseError(operation, "remote_failure", message)
}
for _, key := range []string{"error", "errorCode", "error_code"} {
if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" {
return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key))
}
}
return data, nil
}
func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) {
items, ok := value.([]any)
if !ok {
return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组")
}
out := make([]map[string]any, 0, len(items))
for index, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index))
}
if agoalFirstString(item, idKeys...) == "" {
return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index))
}
out = append(out, item)
}
return out, nil
}
func agoalFirstString(item map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func agoalNonNegativeInt(value any) (int, bool) {
switch typed := value.(type) {
case int:
return typed, typed >= 0
case int64:
if typed < 0 || int64(int(typed)) != typed {
return 0, false
}
return int(typed), true
case float64:
if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) {
return 0, false
}
return int(typed), true
case json.Number:
parsed, err := typed.Int64()
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
return 0, false
}
return int(parsed), true
default:
return 0, false
}
}
func agoalReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func agoalUnavailableSafety(write bool) contract.SafetySpec {
if write {
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
}
return agoalReadSafety()
}
func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec {
properties := map[string]any{
"count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"},
collection: map[string]any{
"type": "array", "description": description,
"items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true},
},
}
required := []string{"count", collection}
if paged {
properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"}
properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"}
properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"}
required = append(required, "page", "pageSize", "totalCount")
}
schema, _ := json.Marshal(map[string]any{
"type": "object", "description": description, "additionalProperties": false,
"properties": properties, "required": required,
})
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: schema,
SensitivePaths: append([]string(nil), sensitivePaths...),
}
}
var ReportStatisticsList = shortcut.Shortcut{
Service: productAgoal,
Command: "+report-statistics-list",
Product: productAgoal,
Description: "查询周月报规则提交统计",
Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。",
Risk: shortcut.RiskRead,
Safety: agoalReadSafety(),
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: productAgoal, Name: "shortcut_report_statistics_list",
CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list",
},
Description: "查询周月报规则提交统计",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available",
Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询周月报规则提交统计",
UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"},
AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"},
Examples: []string{"dws agoal +report-statistics-list --keyword <KEYWORD>"},
},
Result: agoalListResult("statistics", "周月报规则提交统计记录", false,
"statistics.title", "statistics.lastModifier", "statistics.content.name"),
},
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Changed("keyword") {
params["keyword"] = rt.Str("keyword")
}
raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params)
if err != nil {
return err
}
if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil {
return err
}
items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId")
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(items), "statistics": items})
},
}
var ObjectTemplateList = shortcut.Shortcut{
Service: productAgoal,
Command: "+obj-template-list",
Product: productAgoal,
Description: "分页查询 Agoal 目标模板",
Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。",
Risk: shortcut.RiskRead,
Safety: agoalReadSafety(),
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: productAgoal, Name: "shortcut_obj_template_list",
CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list",
},
Description: "分页查询 Agoal 目标模板",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available",
Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.",
},
Selection: contract.SelectionSpec{
AgentSummary: "分页查询 Agoal 目标模板",
UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"},
AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"},
Examples: []string{"dws agoal +obj-template-list --keyword <KEYWORD> --page 1 --page-size 20"},
},
Result: agoalListResult("templates", "Agoal 目标模板查询结果", true,
"templates.title", "templates.creator", "templates.dimensions.title"),
},
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"},
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page") < 1 {
return apperrors.NewValidation("--page 必须大于等于 1")
}
if size := rt.Int("page-size"); size < 1 || size > 100 {
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
wantPage := rt.Int("page")
wantSize := rt.Int("page-size")
params := map[string]any{"page": wantPage, "pageSize": wantSize}
if rt.Changed("keyword") {
params["keyword"] = rt.Str("keyword")
}
raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params)
if err != nil {
return err
}
if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil {
return err
}
content, ok := raw["content"].(map[string]any)
if !ok {
return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象")
}
items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId")
if err != nil {
return err
}
page, pageOK := agoalNonNegativeInt(content["page"])
pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"])
total, totalOK := agoalNonNegativeInt(content["totalCount"])
if !pageOK || !pageSizeOK || !totalOK {
return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
}
if page != wantPage || pageSize != wantSize {
return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致")
}
if total < len(items) {
return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数")
}
return rt.Output(map[string]any{
"count": len(items), "templates": items,
"page": page, "pageSize": pageSize, "totalCount": total,
})
},
}
var ContractFields = shortcut.Shortcut{
Service: productAgoal, Command: "+contract-fields", Product: productAgoal,
Description: "查询经营合约字段配置",
Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。",
Risk: shortcut.RiskRead,
Safety: agoalReadSafety(),
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: productAgoal, Name: "shortcut_contract_fields",
CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields",
},
Description: "查询并严格筛选经营合约字段配置",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available",
Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询经营合约字段配置",
UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"},
AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"},
Examples: []string{"dws agoal +contract-fields --keyword <KEYWORD>"},
},
Result: agoalListResult("fields", "经营合约字段配置", false),
},
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}},
Execute: func(rt *shortcut.RuntimeContext) error {
raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{})
if err != nil {
return err
}
if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil {
return err
}
fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id")
if err != nil {
return err
}
fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type")
return rt.Output(map[string]any{"count": len(fields), "fields": fields})
},
}
var UserRules = shortcut.Shortcut{
Service: productAgoal, Command: "+user-rules", Product: productAgoal,
Description: "查询用户 Agoal 规则周期",
Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。",
Risk: shortcut.RiskRead,
Safety: agoalReadSafety(),
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: productAgoal, Name: "shortcut_user_rules",
CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules",
},
Description: "查询用户规则周期并可按稳定 ruleId 精确筛选",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available",
Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询用户 Agoal 规则周期",
UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"},
AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"},
Examples: []string{"dws agoal +user-rules --rule-id <RULE_ID>"},
},
Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false,
"rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"),
},
Flags: []shortcut.Flag{
{Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"},
{Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Changed("user-id") {
params["dingUserId"] = rt.Str("user-id")
}
raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params)
if err != nil {
return err
}
if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil {
return err
}
content, ok := raw["content"].(map[string]any)
if !ok {
return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象")
}
rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id")
if err != nil {
return err
}
wantRule := strings.TrimSpace(rt.Str("rule-id"))
if wantRule != "" {
filtered := make([]map[string]any, 0, 1)
for _, rule := range rules {
if agoalFirstString(rule, "id") == wantRule {
filtered = append(filtered, rule)
}
}
rules = filtered
}
return rt.Output(map[string]any{"count": len(rules), "rules": rules})
},
}
var ReportSubmitDetail = shortcut.Shortcut{
Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal,
Description: "分页查询周月报人员提交详情",
Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。",
Risk: shortcut.RiskRead,
Safety: agoalReadSafety(),
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: productAgoal, Name: "shortcut_report_submit_detail",
CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail",
},
Description: "分页查询周月报人员提交详情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available",
Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.",
},
Selection: contract.SelectionSpec{
AgentSummary: "分页查询周月报人员提交详情",
UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"},
AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"},
Examples: []string{"dws agoal +report-submit-detail --template-id <TEMPLATE_ID> --submit-state NOT_SUBMITTED --page 1 --page-size 20"},
},
Result: agoalListResult("submissions", "周月报人员提交详情", true,
"submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"),
},
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true},
{Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}},
{Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"},
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page") < 1 {
return apperrors.NewValidation("--page 必须大于等于 1")
}
if size := rt.Int("page-size"); size < 1 || size > 100 {
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
}
if rt.Changed("query-date") {
if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil {
return err
}
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
wantPage, wantSize := rt.Int("page"), rt.Int("page-size")
baseParams := map[string]any{
"templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")),
}
if rt.Changed("query-date") {
// Validate has already accepted this exact immutable Cobra value.
queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date"))
baseParams["queryDate"] = queryDate
}
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total})
}
all, err := readAllAgoalSubmissions(rt, baseParams)
if err != nil {
return err
}
filtered := filterAgoalSubmissions(all, keyword)
start := len(filtered)
pageOffset := wantPage - 1
if pageOffset <= len(filtered)/wantSize {
start = pageOffset * wantSize
}
end := start + wantSize
if end > len(filtered) {
end = len(filtered)
}
items := filtered[start:end]
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)})
},
}
const (
agoalSubmissionReadPageSize = 100
agoalSubmissionMaxPages = 20
)
func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) {
params := make(map[string]any, len(baseParams)+2)
for key, value := range baseParams {
params[key] = value
}
params["page"] = page
params["pageSize"] = pageSize
raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params)
if err != nil {
return nil, 0, err
}
if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil {
return nil, 0, err
}
content, ok := raw["content"].(map[string]any)
if !ok {
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象")
}
items, err := requireAgoalSubmissionList(content["result"])
if err != nil {
return nil, 0, err
}
gotPage, pageOK := agoalNonNegativeInt(content["page"])
gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"])
total, totalOK := agoalNonNegativeInt(content["totalCount"])
if !pageOK || !sizeOK || !totalOK {
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
}
if gotPage != page || gotSize != pageSize {
return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致")
}
if total < len(items) {
return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数")
}
return items, total, nil
}
func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) {
all := make([]map[string]any, 0)
seen := map[string]bool{}
wantTotal := -1
for page := 1; page <= agoalSubmissionMaxPages; page++ {
items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize)
if err != nil {
return nil, err
}
if wantTotal < 0 {
wantTotal = total
} else if total != wantTotal {
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化")
}
for _, item := range items {
identity := agoalSubmissionIdentity(item)
if seen[identity] {
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份")
}
seen[identity] = true
all = append(all, item)
}
if len(all) == wantTotal {
return all, nil
}
if len(items) == 0 {
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进")
}
}
return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限")
}
func agoalSubmissionIdentity(item map[string]any) string {
if reportID := agoalFirstString(item, "reportId"); reportID != "" {
return "report:" + reportID
}
user, _ := item["user"].(map[string]any)
return "user:" + agoalFirstString(user, "dingUserId", "id")
}
func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any {
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" {
return items
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
user, _ := item["user"].(map[string]any)
if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) {
out = append(out, item)
}
}
return out
}
func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any {
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" {
return items
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
for _, key := range keys {
value, ok := item[key].(string)
if ok && strings.Contains(strings.ToLower(value), keyword) {
out = append(out, item)
break
}
}
}
return out
}
func requireAgoalSubmissionList(value any) ([]map[string]any, error) {
rawItems, ok := value.([]any)
if !ok {
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组")
}
out := make([]map[string]any, 0, len(rawItems))
for index, raw := range rawItems {
item, ok := raw.(map[string]any)
if !ok {
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index))
}
user, ok := item["user"].(map[string]any)
if !ok || agoalFirstString(user, "dingUserId", "id") == "" {
return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index))
}
projectedUser := map[string]any{}
for _, key := range []string{"dingUserId", "id", "name", "workNo"} {
if value, found := user[key]; found {
projectedUser[key] = value
}
}
projected := map[string]any{"user": projectedUser}
for _, key := range []string{"reportId", "publishTime"} {
if value, found := item[key]; found {
projected[key] = value
}
}
out = append(out, projected)
}
return out, nil
}
func normalizeAgoalQueryDate(value string) (string, error) {
value = strings.TrimSpace(value)
if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed.Format("2006-01-02"), nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
location, loadErr := agoalLoadLocation("Asia/Shanghai")
if loadErr != nil {
location = time.FixedZone("CST", 8*60*60)
}
return parsed.In(location).Format("2006-01-02"), nil
}
return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间")
}
type unavailableAgoalSpec struct {
command string
description string
intent string
tool string
flags []shortcut.Flag
write bool
reason string
}
func agoalStringFlag(name, description string, required bool) shortcut.Flag {
return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required}
}
func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut {
flagNames := make([]string, 0, len(spec.flags))
for _, flag := range spec.flags {
flagNames = append(flagNames, flag.Name)
}
unavailable := func(*shortcut.RuntimeContext) error {
return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason)
}
return shortcut.Shortcut{
Service: productAgoal, Command: spec.command, Product: productAgoal,
Description: spec.description, Intent: spec.intent,
Risk: func() shortcut.Risk {
if spec.write {
return shortcut.RiskHighWrite
}
return shortcut.RiskRead
}(),
Safety: agoalUnavailableSafety(spec.write),
Flags: spec.flags,
Hidden: true,
Availability: shortcut.AvailabilityUnavailable,
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom, Flags: flagNames,
Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足",
}},
SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason,
Validate: unavailable,
Execute: unavailable,
}
}
var unavailableAgoalSpecs = []unavailableAgoalSpec{
{command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
{command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{
agoalStringFlag("profile-id", "战略解码稳定 ID", true),
}, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
{command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{
agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true),
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
{command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
{command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{
agoalStringFlag("contract-id", "经营合约稳定 ID", true),
}, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
{command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{
agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true),
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
{command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{
agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true),
}, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"},
{command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{
agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true),
}, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"},
{command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{
agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true),
{Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}},
agoalStringFlag("content", "完整计分卡 JSON 数组", true),
}, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"},
{command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{
agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true),
}, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"},
{command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{
agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true),
{Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"},
{Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"},
{Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"},
}, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"},
}
func init() {
contract.RegisterProductDecl(contract.ProductDecl{
ID: productAgoal,
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放",
UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"},
AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"},
},
})
items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail}
for _, spec := range unavailableAgoalSpecs {
items = append(items, unavailableAgoalShortcut(spec))
}
for index := range items {
if items[index].Availability != shortcut.AvailabilityUnavailable {
items[index].OutputRollout = output.RolloutUnifiedActive
}
}
shortcut.Register(items...)
}
@@ -0,0 +1,201 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package agoal
import (
"encoding/json"
"errors"
"math"
"strconv"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
want int
ok bool
}{
{name: "int", value: 3, want: 3, ok: true},
{name: "negative int", value: -1, want: -1},
{name: "int64", value: int64(4), want: 4, ok: true},
{name: "negative int64", value: int64(-1)},
{name: "float", value: float64(5), want: 5, ok: true},
{name: "negative float", value: float64(-1)},
{name: "fraction", value: 1.5},
{name: "huge float", value: math.MaxFloat64},
{name: "json number", value: json.Number("6"), want: 6, ok: true},
{name: "bad json number", value: json.Number("1.5")},
{name: "negative json number", value: json.Number("-1")},
{name: "wrong type", value: "7"},
} {
t.Run(tc.name, func(t *testing.T) {
got, ok := agoalNonNegativeInt(tc.value)
if got != tc.want || ok != tc.ok {
t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok)
}
})
}
items := []map[string]any{{"id": "one", "name": "Known"}}
if got := filterAgoalItems(items, "", "name"); len(got) != 1 {
t.Fatalf("empty item filter = %#v", got)
}
if got := filterAgoalSubmissions(items, ""); len(got) != 1 {
t.Fatalf("empty submission filter = %#v", got)
}
if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" {
t.Fatalf("fallback identity = %q", got)
}
for _, bad := range []any{
nil,
[]any{"bad"},
[]any{map[string]any{"user": "bad"}},
[]any{map[string]any{"user": map[string]any{"name": "missing identity"}}},
} {
if _, err := requireAgoalSubmissionList(bad); err == nil {
t.Fatalf("malformed submission list accepted: %#v", bad)
}
}
}
func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) {
if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" {
t.Fatalf("date normalization = %q, %v", got, err)
}
if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" {
t.Fatalf("RFC3339 normalization = %q, %v", got, err)
}
testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) {
return nil, errors.New("fixture location failure")
})
if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" {
t.Fatalf("fallback timezone normalization = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) {
tests := []struct {
name string
declaration shortcut.Shortcut
args []string
tool string
response map[string]any
}{
{name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"},
{name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}},
{name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
{name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}},
{name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}},
{name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"},
{name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}},
{name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
{name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}},
{name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}},
{name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
{name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
{name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}},
{name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"},
{name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}},
{name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
{name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}},
{name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}},
{name: "rules transport", declaration: UserRules, tool: "get_user_rules"},
{name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}},
{name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
{name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}},
{name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}},
{name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}},
{name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"},
{name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}},
{name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
{name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}},
{name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}},
{name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
{name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
{name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &agoalCoverageCaller{}
if tc.response != nil {
caller.responses = map[string][]map[string]any{tc.tool: {tc.response}}
}
if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil {
t.Fatal("failure fixture was accepted")
}
})
}
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}},
}}
if err := runAgoalCoverage(t, UserRules, caller); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) {
responses := make([]map[string]any, 0, agoalSubmissionMaxPages)
for page := 1; page <= agoalSubmissionMaxPages; page++ {
responses = append(responses, map[string]any{"success": true, "content": map[string]any{
"page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1,
"result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}},
}})
}
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}}
if err := runAgoalCoverage(t, ReportSubmitDetail, caller,
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
t.Fatal("bounded pagination limit was accepted as complete")
}
scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"content": map[string]any{}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure,
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
t.Fatal("scan page validation failure was accepted")
}
dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{},
}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller,
"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil {
t.Fatal(err)
}
if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" {
t.Fatalf("queryDate = %#v", got)
}
laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage,
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil {
t.Fatal(err)
}
maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage,
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing",
"--page", strconv.Itoa(int(^uint(0)>>1))); err != nil {
t.Fatalf("maximum page must project an empty local page without overflowing: %v", err)
}
}
@@ -0,0 +1,434 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package agoal
import (
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type agoalCoverageCall struct {
tool string
params map[string]any
}
type agoalCoverageCaller struct {
responses map[string][]map[string]any
calls []agoalCoverageCall
}
func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params})
queue := c.responses[tool]
if len(queue) == 0 {
return nil, errors.New("unexpected agoal coverage call: " + tool)
}
payload := queue[0]
c.responses[tool] = queue[1:]
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
}
func (*agoalCoverageCaller) Format() string { return "json" }
func (*agoalCoverageCaller) DryRun() bool { return false }
func (*agoalCoverageCaller) Fields() string { return "" }
func (*agoalCoverageCaller) JQ() string { return "" }
func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: productAgoal}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(""))
root.SetArgs(append([]string{productAgoal, declaration.Command}, args...))
return root.Execute()
}
func agoalRegistered(command string) (shortcut.Shortcut, bool) {
for _, item := range shortcut.All() {
if item.Service == productAgoal && item.Command == command {
return item, true
}
}
return shortcut.Shortcut{}, false
}
func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) {
for _, tc := range []struct {
name string
data map[string]any
}{
{name: "empty", data: nil},
{name: "missing success", data: map[string]any{"content": []any{}}},
{name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}},
{name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}},
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil {
t.Fatal("malformed response was accepted")
}
})
}
for _, tc := range []struct {
name string
value any
}{
{name: "missing collection", value: nil},
{name: "wrong collection", value: map[string]any{}},
{name: "bad element", value: []any{"bad"}},
{name: "missing id", value: []any{map[string]any{"title": "template"}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil {
t.Fatal("malformed collection was accepted")
}
})
}
}
func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) {
t.Run("report nonempty", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}},
}}
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("report legal zero", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"list_report_statistics": {{"success": true, "content": []any{}}},
}}
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil {
t.Fatal(err)
}
})
t.Run("template nonempty page", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"list_obj_template": {{"success": true, "content": map[string]any{
"page": 2, "pageSize": 5, "totalCount": 6,
"result": []any{map[string]any{"id": "template"}},
}}},
}}
if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("template rejects false pagination", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"list_obj_template": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": 20, "totalCount": 0,
"result": []any{map[string]any{"id": "impossible"}},
}}},
}}
if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil {
t.Fatal("impossible pagination total was accepted")
}
})
}
func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) {
t.Run("contract fields known and guaranteed zero", func(t *testing.T) {
items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}}
if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 {
t.Fatalf("known field projection = %#v", got)
}
if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 {
t.Fatalf("zero field projection = %#v", got)
}
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"list_op_contract_fields": {{"success": true, "content": []any{items[0]}}},
}}
if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("user rules exact filter", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_user_rules": {{"success": true, "content": map[string]any{
"rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}},
}}},
}}
if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
zero := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_user_rules": {{"success": true, "content": map[string]any{
"rules": []any{map[string]any{"id": "rule-1"}},
}}},
}}
if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil {
t.Fatal(err)
}
})
t.Run("report submission page and privacy identity", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": 20, "totalCount": 1,
"result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}},
}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20,
}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("report keyword scans then filters locally", func(t *testing.T) {
items := []map[string]any{
{"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}},
{"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}},
}
if got := filterAgoalSubmissions(items, "known"); len(got) != 1 {
t.Fatalf("known submission projection = %#v", got)
}
if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 {
t.Fatalf("zero submission projection = %#v", got)
}
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": map[string]any{
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2,
"result": []any{items[0], items[1]},
}}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil {
t.Fatal(err)
}
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize,
}}}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) {
item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
t.Fatal("duplicate submission pagination was accepted")
}
})
t.Run("report keyword scan rejects changing total", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}},
},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
t.Fatal("changing pagination total was accepted")
}
})
t.Run("report keyword scan rejects stalled page", func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}},
},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
t.Fatal("stalled pagination was accepted")
}
})
t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) {
items, err := requireAgoalSubmissionList([]any{map[string]any{
"reportId": "report", "unreviewed": "drop",
"user": map[string]any{"id": "user", "name": "Known", "email": "drop"},
}})
if err != nil {
t.Fatal(err)
}
if _, found := items[0]["unreviewed"]; found {
t.Fatal("unreviewed submission field was projected")
}
user := items[0]["user"].(map[string]any)
if _, found := user["email"]; found {
t.Fatal("unreviewed personnel field was projected")
}
})
for _, tc := range []struct {
name string
content map[string]any
}{
{name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}},
{name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}},
{name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}},
} {
t.Run("report rejects "+tc.name, func(t *testing.T) {
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
"get_submit_detail": {{"success": true, "content": tc.content}},
}}
if err := runAgoalCoverage(t, ReportSubmitDetail,
caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil {
t.Fatal("malformed submission page was accepted")
}
})
}
}
func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) {
for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} {
caller := &agoalCoverageCaller{}
err := runAgoalCoverage(t, ObjectTemplateList, caller, args...)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("invalid args %v error = %#v", args, err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls)
}
}
for _, args := range [][]string{
{"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"},
{"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"},
{"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"},
} {
caller := &agoalCoverageCaller{}
if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil {
t.Fatalf("invalid submission args %v were accepted", args)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls)
}
}
}
func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) {
total := 0
public := 0
unavailable := 0
for _, item := range shortcut.All() {
if item.Service != productAgoal {
continue
}
total++
if item.Availability == shortcut.AvailabilityUnavailable {
unavailable++
if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) {
t.Errorf("unavailable %s must remain hidden/non-public", item.Command)
}
continue
}
if shortcut.InPublicCatalog(item.Service, item.Command) {
public++
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
}
if item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
}
if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" {
t.Errorf("public %s has incomplete Contract/Result", item.Command)
}
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
t.Errorf("public %s has incomplete Safety", item.Command)
}
}
}
if total != 16 || public != 5 || unavailable != 11 {
t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable)
}
}
func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) {
for _, spec := range unavailableAgoalSpecs {
declaration, found := agoalRegistered(spec.command)
if !found {
t.Fatalf("registered unavailable command missing: %s", spec.command)
}
args := make([]string, 0)
for _, flag := range declaration.Flags {
if !flag.Required {
continue
}
args = append(args, "--"+flag.Name)
switch flag.Name {
case "scope-type":
args = append(args, "PERSONAL")
case "tracking-period-type":
args = append(args, "MONTHLY")
case "submit-state":
args = append(args, "ON_TIME")
default:
if flag.Type != shortcut.FlagBool {
args = append(args, "fixture")
}
}
}
caller := &agoalCoverageCaller{}
err := runAgoalCoverage(t, declaration, caller, args...)
if err == nil || !strings.Contains(err.Error(), spec.reason) {
t.Errorf("%s unavailable error = %v", spec.command, err)
}
if len(caller.calls) != 0 {
t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls)
}
}
}
+1
View File
@@ -25,6 +25,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
// Service packages — each registers its shortcuts from init().
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,742 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package devapp
import (
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func devAppSuccessResult(values map[string]any) map[string]any {
return map[string]any{"success": true, "result": values}
}
func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any {
result := map[string]any{"success": true, key: items, "hasMore": more}
if cursor != "" {
result["nextCursor"] = cursor
}
return result
}
func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) {
if devAppContainers(nil) != nil {
t.Fatal("nil response unexpectedly produced containers")
}
for _, tc := range []struct {
value any
want bool
}{
{nil, false}, {false, false}, {true, true},
{float64(0), false}, {float64(200), false}, {float64(500), true},
{"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true},
{map[string]any{}, false}, {map[string]any{"code": 1}, true},
{[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true},
} {
if got := devAppErrorValue(tc.value); got != tc.want {
t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want)
}
}
if _, err := requireDevAppSuccess(map[string]any{
"success": true, "result": map[string]any{"versionStatus": "FAILED"},
}, "devapp/test"); err != nil {
t.Fatalf("successful query with failed business state was rejected: %v", err)
}
if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) {
t.Fatal("known envelope was not recognized")
}
if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) {
t.Fatal("business field was treated as envelope-only")
}
if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil {
t.Fatal("empty business object was accepted")
}
object := map[string]any{"id": "actual", "name": "actual"}
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil {
t.Fatal(err)
}
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil {
t.Fatal("missing identity was accepted")
}
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil {
t.Fatal("mismatched identity was accepted")
}
if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil {
t.Fatal("missing readback field was accepted")
}
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil {
t.Fatal("mismatched readback field was accepted")
}
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil {
t.Fatal(err)
}
if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil {
t.Fatal("missing string field was accepted")
}
deep := map[string]any{"success": true, "content": map[string]any{
"result": map[string]any{"hasMore": true, "nextCursor": " next "},
}}
projection, err := devAppListProjection(deep, "items", nil, "devapp/test")
if err != nil || projection["nextCursor"] != "next" {
t.Fatalf("deep pagination projection=%#v err=%v", projection, err)
}
if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil {
t.Fatal("failed list envelope was accepted")
}
}
func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) {
t.Run("list all filters and projection", func(t *testing.T) {
item := map[string]any{
"unified_app_id": "app", "appName": "name", "clientId": "client",
"agent_id": "agent", "app_status": "normal", "modified_time": "now",
}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")},
}}
err := runDevAppCoverage(t, ListApp, caller,
"--name", "name", "--app-key", "client", "--app-group-id", "7",
"--creator", "owner", "--robot-name", "robot", "--develop-type", "2",
"--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc",
"--cursor", "cursor", "--page-size", "9")
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner",
"robotName": "robot", "developType": 2, "filterCoolApp": 1,
"sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9,
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) {
t.Fatalf("list params=%#v, want %#v", caller.calls, want)
}
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}},
}}
if err := runDevAppCoverage(t, ListApp, badPage); err == nil {
t.Fatal("list without terminal pagination evidence was accepted")
}
})
for _, tc := range []struct {
name string
declaration shortcut.Shortcut
tool string
args []string
result map[string]any
}{
{"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}},
{"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}},
{"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}},
{"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}},
{"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}},
{"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}},
{"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}},
{"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}}
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
t.Fatal(err)
}
})
}
t.Run("read transport and identity failure", func(t *testing.T) {
if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
t.Fatal("transport failure was accepted")
}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})},
}}
if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil {
t.Fatal("wrong object identity was accepted")
}
})
t.Run("permission list", func(t *testing.T) {
item := map[string]any{
"permissionCode": "scope", "permissionName": "name", "interfaceName": "api",
"auth_status": "AUTHED", "scope_type": "APP",
}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")},
}}
err := runDevAppCoverage(t, PermissionList, caller,
"--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope",
"--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN")
if err != nil {
t.Fatal(err)
}
if _, ok := permissionListFirst(item, "missing"); ok {
t.Fatal("missing permission field unexpectedly resolved")
}
})
t.Run("member list success and failures", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")},
}}
if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
t.Fatal("member transport failure was accepted")
}
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app_members": {{"success": true, "members": "bad"}},
}}
if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil {
t.Fatal("malformed member list was accepted")
}
})
for _, tc := range []struct {
name string
declaration shortcut.Shortcut
tool string
args []string
key string
item map[string]any
}{
{"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}},
{"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")},
}}
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
t.Fatal(err)
}
})
}
if _, ok := eventListFirst(map[string]any{}, "missing"); ok {
t.Fatal("missing event field unexpectedly resolved")
}
if _, ok := versionListFirst(map[string]any{}, "missing"); ok {
t.Fatal("missing version field unexpectedly resolved")
}
}
func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) {
t.Run("create all fields and failure branches", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
"get_dev_app": {devAppSuccessResult(map[string]any{
"unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon",
})},
}}
if err := runDevAppCoverage(t, CreateApp, caller,
"--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil {
t.Fatal("create transport failure was accepted")
}
for name, responses := range map[string]map[string][]map[string]any{
"receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}},
"resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}},
"readback failure": {
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
},
"name mismatch": {
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
},
"description mismatch": {
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})},
},
"icon mismatch": {
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})},
},
} {
t.Run(name, func(t *testing.T) {
args := []string{"--name", "created", "--yes"}
if strings.Contains(name, "description") {
args = append(args, "--desc", "desc")
}
if strings.Contains(name, "icon") {
args = append(args, "--icon-media-id", "icon")
}
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil {
t.Fatal("invalid create sequence was accepted")
}
})
}
})
t.Run("update all fields and dry run", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"update_dev_app": {map[string]any{"success": true}},
"get_dev_app": {devAppSuccessResult(map[string]any{
"unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon",
})},
}}
if err := runDevAppCoverage(t, UpdateApp, caller,
"--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{},
"--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil {
t.Fatal(err)
}
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}}
if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
t.Fatal("update readback failure was accepted")
}
})
t.Run("delete guards and bounded readback", func(t *testing.T) {
if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
t.Fatal(err)
}
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil {
t.Fatal("delete verification without selector was accepted")
}
missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
}}
if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete without appKey was accepted")
}
writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
}}
if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete transport failure was accepted")
}
stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
"delete_dev_app": {map[string]any{"success": true}},
"list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")},
}}
if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete with present readback was accepted")
}
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
"delete_dev_app": {map[string]any{"success": true}},
"list_dev_app": {
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
},
}}
if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete stalled pagination was accepted")
}
malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
"delete_dev_app": {map[string]any{"success": true}},
"list_dev_app": {{"success": true, "list": "bad", "hasMore": false}},
}}
if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete malformed readback was accepted")
}
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
"delete_dev_app": {map[string]any{"success": true}},
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}},
}}
if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("delete pagination without termination was accepted")
}
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
for page := 0; page < devAppMaxReadbackPages; page++ {
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page))))
}
pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}}
helpers.InitDeps(pageLimitCaller)
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
t.Fatal("delete verification page limit was accepted")
}
helpers.InitDeps(&devAppCoverageCaller{})
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
t.Fatal("delete verification transport failure was accepted")
}
})
t.Run("credentials require client id", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})},
}}
if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil {
t.Fatal("credential without client id was accepted")
}
})
t.Run("webapp all fields and failures", func(t *testing.T) {
args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"}
resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_webapp_config": {map[string]any{"success": true}},
"get_extension_webapp_config": {devAppSuccessResult(resource)},
}}
if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil {
t.Fatal("webapp write failure was accepted")
}
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_webapp_config": {map[string]any{"success": true}},
}}
if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil {
t.Fatal("webapp readback failure was accepted")
}
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_webapp_config": {map[string]any{"success": true}},
"get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})},
}}
if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil {
t.Fatal("webapp mismatch was accepted")
}
})
}
func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) {
t.Run("robot validate and all fields", func(t *testing.T) {
for _, args := range [][]string{
{"--unified-app-id", "app", "--skills", "skill", "--yes"},
{"--unified-app-id", "app", "--add-scope=false", "--yes"},
} {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_robot_config": {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})},
}}
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
t.Fatal(err)
}
}
args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"}
resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_robot_config": {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(resource)},
}}
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil {
t.Fatal("robot write failure was accepted")
}
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}}
if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil {
t.Fatal("robot readback failure was accepted")
}
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_robot_config": {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
}}
if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil {
t.Fatal("robot mismatch was accepted")
}
boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_robot_config": {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})},
}}
if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil {
t.Fatal("robot boolean mismatch was accepted")
}
skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_robot_config": {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})},
}}
if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil {
t.Fatal("robot skills mismatch was accepted")
}
})
t.Run("status previews and readback failures", func(t *testing.T) {
if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
t.Fatal(err)
}
readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
"enable_dev_app": {map[string]any{"success": true}},
}}
if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("app status readback failure was accepted")
}
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
"enable_dev_app": {map[string]any{"success": true}},
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})},
}}
if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("app status mismatch was accepted")
}
if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
t.Fatal(err)
}
robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
"enable_dev_app_robot": {map[string]any{"success": true}},
}}
if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("robot status readback failure was accepted")
}
})
t.Run("member mutations validate receipts and exact readback", func(t *testing.T) {
member := map[string]any{"userId": "user", "memberType": "DEVELOPER"}
add := &devAppCoverageCaller{responses: map[string][]map[string]any{
"add_dev_app_members": {{"success": true}},
"list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")},
}}
if err := runDevAppCoverage(t, MemberAdd, add,
"--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil {
t.Fatal(err)
}
if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" ||
!reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) ||
add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" {
t.Fatalf("member add calls=%#v", add.calls)
}
remove := &devAppCoverageCaller{responses: map[string][]map[string]any{
"remove_dev_app_members": {{"success": true}},
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")},
}}
if err := runDevAppCoverage(t, MemberRemove, remove,
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil {
t.Fatal(err)
}
if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" {
t.Fatalf("member remove calls=%#v", remove.calls)
}
for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} {
unconfirmed := &devAppCoverageCaller{}
if err := runDevAppCoverage(t, declaration, unconfirmed,
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil {
t.Fatalf("%s succeeded without confirmation", declaration.Command)
}
if len(unconfirmed.calls) != 0 {
t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls)
}
dryRun := &devAppCoverageCaller{}
if err := runDevAppCoverage(t, declaration, dryRun,
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil {
t.Fatal(err)
}
if len(dryRun.calls) != 0 {
t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls)
}
}
})
t.Run("member mutations reject invalid input and false success", func(t *testing.T) {
for _, values := range [][]string{nil, {""}, {"user", "user"}} {
if _, err := validatedDevAppValues(values, "--user-ids"); err == nil {
t.Fatalf("invalid member IDs accepted: %#v", values)
}
}
if _, err := validatedDevAppMemberType(" "); err == nil {
t.Fatal("blank member type was accepted")
}
if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" {
t.Fatalf("normalized member type=%q err=%v", got, err)
}
for _, args := range [][]string{
{"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"},
{"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"},
} {
caller := &devAppCoverageCaller{}
if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil {
t.Fatalf("invalid member arguments accepted: %#v", args)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid member arguments made calls: %#v", caller.calls)
}
}
baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"}
badAddResponses := []map[string][]map[string]any{
{},
{"add_dev_app_members": {{"success": false}}},
{"add_dev_app_members": {{"success": true}}},
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}},
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}},
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}},
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}},
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
}, false, "")}},
}
for index, responses := range badAddResponses {
if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil {
t.Fatalf("member add false success case %d was accepted", index)
}
}
removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
"remove_dev_app_members": {{"success": true}},
"list_dev_app_members": {devAppSuccessList("members", []any{
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
}, false, "")},
}}
if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil {
t.Fatal("member remove accepted target still present")
}
})
for _, tc := range []struct {
name string
declaration shortcut.Shortcut
writeTool string
status any
}{
{"enable missing status", RobotEnable, "enable_dev_app_robot", nil},
{"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"},
} {
t.Run(tc.name, func(t *testing.T) {
resource := map[string]any{"unifiedAppId": "app"}
if tc.status != nil {
resource["robotStatus"] = tc.status
}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
tc.writeTool: {map[string]any{"success": true}},
"get_extension_robot_config": {devAppSuccessResult(resource)},
}}
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil {
t.Fatal("invalid robot status was accepted")
}
})
}
t.Run("event validation and readback failures", func(t *testing.T) {
for _, values := range [][]string{nil, {""}, {"event", "event"}} {
if _, err := validatedDevAppValues(values, "--event-codes"); err == nil {
t.Fatalf("invalid event values accepted: %#v", values)
}
}
if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) {
t.Fatalf("normalized event values=%#v err=%v", got, err)
}
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
t.Fatal("event write failure was accepted")
}
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil {
t.Fatal(err)
}
for name, listResponses := range map[string][]map[string]any{
"transport": nil,
"malformed": {{"success": true, "list": "bad", "hasMore": false}},
"projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")},
"stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")},
} {
t.Run(name, func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"subscribe_dev_app_events": {map[string]any{"success": true}},
"list_dev_app_events": listResponses,
}}
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
t.Fatal("invalid event readback was accepted")
}
})
}
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
"subscribe_dev_app_events": {map[string]any{"success": true}},
"list_dev_app_events": {
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
},
}}
if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
t.Fatal("stalled event cursor was accepted")
}
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
for page := 0; page < devAppMaxReadbackPages; page++ {
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page))))
}
pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{
"subscribe_dev_app_events": {map[string]any{"success": true}},
"list_dev_app_events": pages,
}}
if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
t.Fatal("event readback page limit was accepted")
}
})
t.Run("version create all fields and failures", func(t *testing.T) {
args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})},
}}
if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil {
t.Fatal(err)
}
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil {
t.Fatal("version write failure was accepted")
}
for name, responses := range map[string]map[string][]map[string]any{
"receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}},
"missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}},
"readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}},
"version mismatch": {
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})},
},
"description mismatch": {
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})},
},
} {
t.Run(name, func(t *testing.T) {
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil {
t.Fatal("invalid version create sequence was accepted")
}
})
}
})
t.Run("list pagination projection failures", func(t *testing.T) {
for _, tc := range []struct {
declaration shortcut.Shortcut
tool string
args []string
key string
idKey string
}{
{PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"},
{EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"},
{VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"},
} {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}},
}}
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil {
t.Fatalf("%s accepted missing pagination", tc.declaration.Command)
}
}
malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{
"list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}},
}}
if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil {
t.Fatal("malformed event collection was accepted")
}
})
}
@@ -0,0 +1,527 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package devapp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type devAppCoverageCall struct {
tool string
params map[string]any
}
type devAppCoverageCaller struct {
responses map[string][]map[string]any
calls []devAppCoverageCall
}
func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params})
queue := c.responses[tool]
if len(queue) == 0 {
return nil, errors.New("unexpected devapp coverage call: " + tool)
}
payload := queue[0]
c.responses[tool] = queue[1:]
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
}
func (*devAppCoverageCaller) Format() string { return "json" }
func (*devAppCoverageCaller) DryRun() bool { return false }
func (*devAppCoverageCaller) Fields() string { return "" }
func (*devAppCoverageCaller) JQ() string { return "" }
func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "devapp"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(""))
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
return root.Execute()
}
func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) {
t.Helper()
helpers.InitDeps(caller)
ctx, _ := output.WithResultStore(context.Background())
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetContext(ctx)
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "devapp"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(""))
root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
if err := root.Execute(); err != nil {
return nil, err
}
return stdout.Bytes(), nil
}
func devAppRegistered(command string) (shortcut.Shortcut, bool) {
for _, item := range shortcut.All() {
if item.Service == "devapp" && item.Command == command {
return item, true
}
}
return shortcut.Shortcut{}, false
}
func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) {
for _, tc := range []struct {
name string
data map[string]any
}{
{name: "empty", data: nil},
{name: "missing success", data: map[string]any{"list": []any{}}},
{name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}},
{name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}},
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := listAppProject(tc.data); err == nil {
t.Fatal("malformed response was accepted")
}
})
}
for _, tc := range []struct {
name string
data map[string]any
}{
{name: "missing collection", data: map[string]any{"success": true, "hasMore": false}},
{name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}},
{name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}},
{name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := listAppProject(tc.data); err == nil {
t.Fatal("malformed collection was accepted")
}
})
}
valid := []map[string]any{{"unifiedAppId": "app"}}
for _, tc := range []struct {
name string
data map[string]any
}{
{name: "missing pagination", data: map[string]any{"success": true}},
{name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}},
{name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}},
{name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}},
{name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil {
t.Fatal("malformed pagination was accepted")
}
})
}
}
func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) {
writes := []struct {
declaration shortcut.Shortcut
args []string
}{
{CreateApp, []string{"--name", "app"}},
{UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}},
{DeleteApp, []string{"--unified-app-id", "app"}},
{EnableApp, []string{"--unified-app-id", "app"}},
{DisableApp, []string{"--unified-app-id", "app"}},
{WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}},
{RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}},
{RobotEnable, []string{"--unified-app-id", "app"}},
{RobotDisable, []string{"--unified-app-id", "app"}},
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}},
{VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}},
}
for _, tc := range writes {
t.Run(tc.declaration.Command, func(t *testing.T) {
caller := &devAppCoverageCaller{}
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed command called MCP: %#v", caller.calls)
}
})
}
for _, tc := range []struct {
declaration shortcut.Shortcut
args []string
}{
{UpdateApp, []string{"--unified-app-id", "app", "--yes"}},
{WebappConfig, []string{"--unified-app-id", "app", "--yes"}},
{RobotConfig, []string{"--unified-app-id", "app", "--yes"}},
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}},
} {
caller := &devAppCoverageCaller{}
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason == "confirmation_required" {
t.Fatalf("invalid input error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid input called MCP: %#v", caller.calls)
}
}
}
func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) {
t.Run("credentials require stable identity client and secret", func(t *testing.T) {
valid := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
"unifiedAppId": "app", "appKey": "client", "appSecret": "secret",
}}},
}}
if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil {
t.Fatal(err)
}
missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
"unifiedAppId": "app", "appKey": "client",
}}},
}}
if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil {
t.Fatal("credential response without a secret was accepted")
}
})
t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) {
declaration, ok := devAppRegistered("+credentials-get")
if !ok || declaration.OutputRollout != output.RolloutUnifiedActive {
t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout)
}
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
"unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret",
"clientSecret": "test-client-secret", "secret": "test-secret",
}}},
}}
encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app")
if err != nil {
t.Fatal(err)
}
var envelope map[string]any
if err := json.Unmarshal(encoded, &envelope); err != nil {
t.Fatalf("unified output is not JSON: %v", err)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified output data type = %T, want object", envelope["data"])
}
if _, nested := data["result"]; nested {
t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it")
}
for _, path := range declaration.Contract.Result.SensitivePaths {
if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" {
t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path)
}
}
})
t.Run("member projector supports known and guaranteed zero", func(t *testing.T) {
members := []any{
map[string]any{"userId": "user-1", "memberType": "OWNER"},
map[string]any{"userId": "user-2", "memberType": "DEVELOPER"},
}
if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" {
t.Fatalf("known member projection = %#v", got)
}
if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 {
t.Fatalf("zero member projection = %#v", got)
}
for _, bad := range []map[string]any{
{"success": true, "members": []any{"bad"}},
{"success": true, "members": []any{map[string]any{"name": "missing-id"}}},
} {
if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil {
t.Fatalf("malformed member response accepted: %#v", bad)
}
}
})
for _, tc := range []struct {
name string
declaration shortcut.Shortcut
args []string
responses map[string][]map[string]any
wantTools []string
}{
{
name: "robot config", declaration: RobotConfig,
args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"},
responses: map[string][]map[string]any{
"set_extension_robot_config": {{"success": true}},
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}},
},
wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"},
},
{
name: "robot enable", declaration: RobotEnable,
args: []string{"--unified-app-id", "app", "--yes"},
responses: map[string][]map[string]any{
"enable_dev_app_robot": {{"success": true}},
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}},
},
wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"},
},
{
name: "robot disable", declaration: RobotDisable,
args: []string{"--unified-app-id", "app", "--yes"},
responses: map[string][]map[string]any{
"disable_dev_app_robot": {{"success": true}},
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}},
},
wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"},
},
{
name: "event subscribe", declaration: EventSubscribe,
args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"},
responses: map[string][]map[string]any{
"subscribe_dev_app_events": {{"success": true}},
"list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}},
},
wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"},
},
{
name: "version create", declaration: VersionCreate,
args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"},
responses: map[string][]map[string]any{
"create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}},
"get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}},
},
wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"},
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &devAppCoverageCaller{responses: tc.responses}
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
t.Fatal(err)
}
gotTools := make([]string, 0, len(caller.calls))
for _, call := range caller.calls {
gotTools = append(gotTools, call.tool)
}
if !reflect.DeepEqual(gotTools, tc.wantTools) {
t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools)
}
})
}
t.Run("event false receipt is rejected by readback", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"subscribe_dev_app_events": {{"success": true}},
"list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}},
}}
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil {
t.Fatal("event subscription without exact readback was accepted")
}
})
}
func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) {
t.Run("create", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}},
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}},
}}
if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil {
t.Fatal(err)
}
want := []devAppCoverageCall{
{tool: "create_dev_app", params: map[string]any{"name": "created"}},
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
t.Run("update mismatch fails", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"update_dev_app": {{"success": true}},
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}},
}}
if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
t.Fatal("write/readback mismatch was accepted")
}
if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" {
t.Fatalf("calls = %#v", caller.calls)
}
})
for _, tc := range []struct {
name string
declaration shortcut.Shortcut
writeTool string
status string
}{
{name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"},
{name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
tc.writeTool: {{"success": true}},
"get_dev_app": {{"success": true, "result": map[string]any{
"unifiedAppId": "app", "appStatus": tc.status,
}}},
}}
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
t.Run("webapp config", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"set_extension_webapp_config": {{"success": true}},
"get_extension_webapp_config": {{"success": true, "result": map[string]any{
"unifiedAppId": "app", "homepageUrl": "https://example.invalid",
}}},
}}
if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" {
t.Fatalf("calls = %#v", caller.calls)
}
})
t.Run("delete pre-read and absence", func(t *testing.T) {
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}},
"delete_dev_app": {{"success": true}},
"list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}},
}}
if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil {
t.Fatal(err)
}
want := []devAppCoverageCall{
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
{tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}},
{tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}},
}
if !reflect.DeepEqual(caller.calls, want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
})
}
func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) {
public := 0
unavailable := 0
total := 0
for _, item := range shortcut.All() {
if item.Service != "devapp" {
continue
}
total++
if item.Availability == shortcut.AvailabilityUnavailable {
unavailable++
if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) {
t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v",
item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command))
}
if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" {
t.Errorf("unavailable %s lacks concrete reason", item.Command)
}
if !item.Contract.Empty() {
t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command)
}
continue
}
if shortcut.InPublicCatalog(item.Service, item.Command) {
public++
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
}
if item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
}
if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" {
t.Errorf("public %s has incomplete Contract/Result", item.Command)
}
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
t.Errorf("public %s has incomplete Safety", item.Command)
}
}
}
if total != 30 || public != 25 || unavailable != 5 {
t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable)
}
}
func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) {
for command, reason := range devAppUnavailableReasons {
declaration, found := devAppRegistered(command)
if !found {
t.Fatalf("registered unavailable command missing: %s", command)
}
args := []string{}
for _, flag := range declaration.Flags {
if !flag.Required {
continue
}
args = append(args, "--"+flag.Name)
if flag.Type != shortcut.FlagBool {
args = append(args, "fixture")
}
}
caller := &devAppCoverageCaller{}
err := runDevAppCoverage(t, declaration, caller, args...)
if err == nil || !strings.Contains(err.Error(), reason) {
t.Errorf("%s unavailable error = %v", command, err)
}
if len(caller.calls) != 0 {
t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls)
}
}
}
@@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
name string
data map[string]any
}{
{name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}},
{name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
{name: "exhausted", data: map[string]any{"hasMore": false}},
{name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}},
{name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
{name: "exhausted", data: map[string]any{"success": true, "hasMore": false}},
} {
t.Run(tc.name, func(t *testing.T) {
got := devAppListProjection(tc.data, "items", items)
got, err := devAppListProjection(tc.data, "items", items, "devapp/test")
if err != nil {
t.Fatal(err)
}
if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" {
t.Fatalf("hasMore not preserved: %#v", got)
}
@@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
if got := devAppPaginationCandidates(nil); got != nil {
t.Fatalf("nil candidates=%#v", got)
}
deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" {
deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" {
t.Fatalf("deep projection=%#v", got)
}
deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}}
if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" {
t.Fatalf("deeper projection=%#v", got)
}
cyclic := map[string]any{"success": true}
cyclic["content"] = cyclic
if got := devAppPaginationCandidates(cyclic); len(got) != 13 {
t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got))
}
}
func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) {
caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
helpers.InitDepsForTest(t, caller)
helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{})
for _, tc := range []struct {
@@ -12,10 +12,16 @@ import (
func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
active := map[string]bool{
"+list": true, "+get": true, "+webapp-get": true,
"+permission-list": true, "+event-list": true, "+version-list": true,
"+list": true, "+get": true, "+create": true, "+update": true,
"+delete": true, "+enable": true, "+disable": true, "+webapp-get": true,
"+webapp-config": true,
"+permission-list": true, "+version-list": true,
"+robot-get": true, "+version-get": true,
"+version-check-approval": true, "+version-status": true,
"+credentials-get": true, "+member-list": true,
"+member-add": true, "+member-remove": true,
"+robot-config": true, "+robot-enable": true, "+robot-disable": true,
"+event-list": true, "+event-subscribe": true, "+version-create": true,
}
seen := map[string]bool{}
for _, item := range shortcut.All() {
@@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
t.Errorf("active pilot shortcut %s is not registered", name)
}
}
for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} {
for _, paginated := range []string{"+list", "+permission-list", "+version-list"} {
if !seen[paginated] {
t.Errorf("paginated shortcut %s is not registered", paginated)
}
@@ -6,6 +6,11 @@ package shortcut
// command discovery and skill generation.
func generatedPublicShortcutCatalog() map[string]struct{} {
return map[string]struct{}{
"agoal\u0000+contract-fields": {},
"agoal\u0000+obj-template-list": {},
"agoal\u0000+report-statistics-list": {},
"agoal\u0000+report-submit-detail": {},
"agoal\u0000+user-rules": {},
"aisearch\u0000+search-person": {},
"aitable\u0000+advperm-disable": {},
"aitable\u0000+advperm-enable": {},
@@ -254,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"contact\u0000+search-user": {},
"contact\u0000+team": {},
"devapp\u0000+create": {},
"devapp\u0000+credentials-get": {},
"devapp\u0000+delete": {},
"devapp\u0000+disable": {},
"devapp\u0000+enable": {},
"devapp\u0000+event-list": {},
"devapp\u0000+event-subscribe": {},
"devapp\u0000+get": {},
"devapp\u0000+list": {},
"devapp\u0000+member-add": {},
"devapp\u0000+member-list": {},
"devapp\u0000+member-remove": {},
"devapp\u0000+permission-list": {},
"devapp\u0000+robot-config": {},
"devapp\u0000+robot-disable": {},
"devapp\u0000+robot-enable": {},
"devapp\u0000+robot-get": {},
"devapp\u0000+update": {},
"devapp\u0000+version-check-approval": {},
"devapp\u0000+version-create": {},
"devapp\u0000+version-get": {},
"devapp\u0000+version-list": {},
"devapp\u0000+version-status": {},
+2 -4
View File
@@ -16,6 +16,7 @@ package shortcut
import (
"sort"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
@@ -68,10 +69,7 @@ func build(shortcuts []Shortcut) []*cobra.Command {
for _, s := range shortcuts {
parent, ok := byService[s.Service]
if !ok {
parent = &cobra.Command{
Use: s.Service,
Short: s.Service + " shortcuts",
}
parent = cobracmd.NewGroupCommand(s.Service, s.Service+" shortcuts")
byService[s.Service] = parent
order = append(order, s.Service)
}
+17
View File
@@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error {
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
}
// OutputForTool stores a once-fetched MCP payload while retaining the tool name
// for product-specific outcome normalization. It is intended for composite
// shortcuts that must validate stable identity or collection shape before the
// shared renderer sees the response; callers must not use it to issue a second
// business request.
func (rt *RuntimeContext) OutputForTool(tool string, payload any) error {
if output.UsesUnifiedResult(rt.cmd) {
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
}
if output.CommandRollout(rt.cmd) == output.RolloutDualValidate {
if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil {
return err
}
}
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
}
func (rt *RuntimeContext) storePayload(tool string, payload any) error {
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
}
+51
View File
@@ -14,6 +14,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) {
t.Fatalf("devapp result=%s", got.Outcome())
}
}
func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) {
t.Run("unified", func(t *testing.T) {
ctx, _ := output.WithResultStore(context.Background())
cmd := &cobra.Command{Use: "+get"}
cmd.SetContext(ctx)
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
t.Fatal(err)
}
})
t.Run("dual validate", func(t *testing.T) {
cmd := &cobra.Command{Use: "+get"}
cmd.SetContext(context.Background())
cmd.SetOut(&bytes.Buffer{})
output.SetCommandRollout(cmd, output.RolloutDualValidate)
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
t.Fatal(err)
}
})
t.Run("dual validate failure", func(t *testing.T) {
testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled })
cmd := &cobra.Command{Use: "+get"}
cmd.SetContext(context.Background())
output.SetCommandRollout(cmd, output.RolloutDualValidate)
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) {
t.Fatalf("dual validation error=%v", err)
}
})
t.Run("legacy", func(t *testing.T) {
cmd := &cobra.Command{Use: "+get"}
cmd.SetContext(context.Background())
var stdout bytes.Buffer
cmd.SetOut(&stdout)
output.SetCommandRollout(cmd, output.RolloutLegacyOnly)
rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Fatalf("legacy output=%q", stdout.String())
}
})
}
+8
View File
@@ -73,6 +73,12 @@ var hrbrainSemanticCatalogJSON []byte
//go:embed semantic_catalog_pat.json
var patSemanticCatalogJSON []byte
//go:embed semantic_catalog_devapp.json
var devAppSemanticCatalogJSON []byte
//go:embed semantic_catalog_agoal.json
var agoalSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -113,6 +119,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
devdocSemanticCatalogJSON,
hrbrainSemanticCatalogJSON,
patSemanticCatalogJSON,
devAppSemanticCatalogJSON,
agoalSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {

Some files were not shown because too many files have changed in this diff Show More