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
克谨 9401f9921a merge: sync latest main 2026-08-25 00:09:27 +08:00
github-actions[bot] ed951ff0e1 chore: update beta formula for v1.0.60-beta.2 [skip ci] 2026-08-24 15:57:43 +00:00
赤川 b42596214d Merge pull request #1133 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2-amendment
docs: amend v1.0.60-beta.2 release notes
2026-08-24 23:42:06 +08:00
chichuan f684c412ea docs: amend v1.0.60-beta.2 release notes 2026-08-24 23:37:21 +08:00
克谨 a4813f78bc fix(todo): preserve priority schema compatibility 2026-08-24 23:33:43 +08:00
赤川 415d962371 Merge pull request #1131 from DingTalk-Real-AI/codex/fix-pretag-beta-seal-amendment
fix(policy): allow audited pre-tag beta amendments
2026-08-24 23:13:59 +08:00
chichuan 26253a4eeb fix(policy): allow audited pre-tag beta amendments 2026-08-24 23:11:30 +08:00
克谨 46fdaed90b Merge remote-tracking branch 'origin/main' into codex/fix-todo-upstream-internal-error 2026-08-24 23:10:17 +08:00
克谨 9ea19315e9 fix(todo): classify upstream failures and validate inputs 2026-08-24 23:04:45 +08:00
github-actions[bot] 8549a90402 Merge pull request #1127 from DingTalk-Real-AI/codex/param-hallucination-6-products
feat(cli): govern six product parameter aliases
2026-08-24 22:59:59 +08:00
克谨 a5dbd2e049 Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:33:48 +08:00
github-actions[bot] 0ebdcb455d Merge pull request #1105 from maoqxxmm/codex/sheet-float-image-local-file
feat(sheet): support local files for float images
2026-08-24 14:30:48 +00:00
克谨 b1a6f9e20c Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:03:15 +08:00
xiatian 29a0dde9d4 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 22:02:38 +08:00
克谨 0fae9dd8b3 feat(cli): govern six product parameter aliases 2026-08-24 22:01:10 +08:00
赤川 6096227511 Merge pull request #1126 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2
chore(release): prepare v1.0.60-beta.2
2026-08-24 22:00:23 +08:00
chichuan aa265acd50 chore(release): prepare v1.0.60-beta.2 2026-08-24 21:46:54 +08:00
赤川 7210e5677c Merge pull request #1121 from hlzjsong/refresh_legacy_slot_fix
recover identity refresh from legacy global slot
2026-08-24 21:38:58 +08:00
赤川 1c90623e31 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 21:38:34 +08:00
github-actions[bot] e7236e27d4 Merge pull request #1101 from zh-tinghe/feat/migrate-edu-contact
feat: add edu & college vendor extension commands
2026-08-24 13:33:58 +00:00
赤川 e73ebab2db Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 21:03:27 +08:00
xiatian 1b6a592387 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 20:59:28 +08:00
github-actions[bot] ce7f66ff8e Merge pull request #1117 from maoqxxmm/codex/schema-compat-float-image-one-of
fix(schema): review float image constraint transition
2026-08-24 12:46:49 +00:00
赤川 e58f255476 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 20:28:19 +08:00
赤川 79f4f66d34 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 20:25:16 +08:00
听荷 66061bb0b0 fix(college-contact): 移除可复制破坏性示例中的确认绕过参数并补齐契约示例必填参数
CR P1-1:15 个 user_required 叶子的 Cobra Example 携带 -y/--yes,
Agent 或用户直接复制即绕过本 PR 实现的用户确认门。改为只保留
--dry-run 预览示例(含 -f json 变体),确认参数由调用方在取得
用户明确确认后自行追加。

CR P1-2:create-group-rule 的 Contract.Selection.Examples 缺少必填
--dept-type,示例实际执行会在参数校验阶段失败。同类缺陷在本文件另有
6 处(dept create / employee add / alumni list / alumni add-alumnus /
alumni update-alumnus / graduate batch-update-pending),一并对齐到
各自 Cobra Example;另修正 dept update、dept batch-update-type 契约
示例中 dept-type 取值与 Cobra 示例不一致的问题(standard -> college)。

验证:5 个产品共 153 条 Contract.Selection.Examples 全部 --dry-run
可执行(153/153 ok);changed code coverage 100.0000%。
2026-08-24 20:23:21 +08:00
xiatian 1d14258121 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 20:15:41 +08:00
muling.cs cbbc9a9f90 refresh token legacy_recovery 2026-08-24 20:14:36 +08:00
github-actions[bot] 723d43f660 Merge pull request #1056 from AlwaysLee/feat/drive-permission-get-setting
feat(drive): add permission get-setting command
2026-08-24 12:09:36 +00:00
半圭 12d60d264d fix(drive): address review feedback on permission get-setting
- drop shareScope.required=["linkShare"] from the result schema: the
  production response omits linkShare unless link sharing is configured;
  document the conditional presence in the schema description, tests,
  and both skill references
- spell out policy value tiers in skill docs (READER_AND_ABOVE /
  DOWNLOADER_AND_ABOVE / EDITOR_AND_ABOVE / MANAGER_AND_ABOVE) so the
  shorthand no longer diverges from the schema enums
2026-08-24 19:46:13 +08:00
半圭 7bb2019dfe feat(drive): expose disabledValues with per-value reasons in get-setting schema 2026-08-24 19:46:13 +08:00
半圭 9e692ce0bf docs(drive): align get-setting semantics with final GUI wording
Sync the get-setting schema descriptions and skill references with the
server-side finalized policy texts: policy code semantics now quote the
product permission-settings page labels (e.g. external_share=add
external collaborators, watermark=show watermark, node_spread_scope=
download and distribution scope), the node_spread_scope binary values
read ALL_NODES=all documents and PREVIEWABLE_ONLY=previewable documents
only (online documents, images, videos, etc.), and the contract test
value-semantic fragments follow the same wording. Both mono and multi
drive references also list per-policy name/description examples
(external_share, node_spread with NOBODY, node_move_forbidden) quoted
verbatim from the server i18n single source.
2026-08-24 19:46:13 +08:00
半圭 e2b390217a feat(drive): align get-setting schema with self-explaining policy values
Sync the get_permission_setting result contract with the finalized
server-side enums: switch values ON/OFF become ENABLED/DISABLED and the
node_spread_scope binary ALL_CONTENT becomes ALL_NODES (restriction
applies to all nodes vs previewable-only nodes), while policy codes,
threshold domains, permissionMode and shareScope stay unchanged.
Policies now carry per-entry name and description fields (Chinese label
and value semantics from the server i18n single source) as deterministic
required members, mirrored in the contract test assertions and both mono
and multi drive skill quick references.
2026-08-24 19:46:13 +08:00
半圭 09ecaaa7a1 feat(drive): document get-setting response schema and field semantics
Declare the drive.permission get_setting ResultSpec data schema from the
server-side output metadata so the field-level contract (permissionMode,
shareScope, policy codes and their typed value domains, inherited /
allowedValues semantics) is reviewed code guarded by contract tests, and
add field-quick-reference rows for the get-setting section in both mono
and multi drive skill references.
2026-08-24 19:46:13 +08:00
半圭 95ecc99c41 fix: route drive permission get-setting to drive MCP server 2026-08-24 19:46:13 +08:00
半圭 6df6b3f8dd docs(release): use PR-numbered fragment name for drive permission get-setting 2026-08-24 19:46:13 +08:00
半圭 227861a71d feat(drive): add permission get-setting command 2026-08-24 19:46:13 +08:00
Dennis4477andDennis 20a01aaeff policy: support compatibility-visible availability migrations (#1123)
Co-authored-by: Dennis <xinyang.dxy@alibaba-inc.com>
2026-08-24 19:37:21 +08:00
赤川 7844b59a8c Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 19:08:53 +08:00
听荷 2a875eee1d Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 19:05:05 +08:00
Dennis4477 c75eda1a2b Merge pull request #1118 from DingTalk-Real-AI/codex/devapp-availability-migration-plan
policy: plan DevApp availability hardening migration
2026-08-24 18:49:08 +08:00
听荷 662352202c docs(skills): complete edu-app command surface and fix dead cross-product ref
Skill 参考此前只描述了 edu-app 42 条叶子里的 16 条,homework(11)/diploma(9)
与 notice 的 6 条全部缺失,导致 Agent 把"作业/通知"一律路由到 task 与
notice confirm;三条 user_required 破坏性命令(notice/homework/diploma
delete)也落在未记录区间,PR 里的确认门禁说明因此无法被文档兑现。
edu-familygroup 的上下文传递表引用了不存在的 edu-contact family list,
真正返回家长信息的是 family parents。

check-skill-commands.sh 只校验以 `dws ` 开头的反引号片段,因此上述两处
死引用能在门禁全绿的情况下存活;本次给跨产品引用统一补上 dws 前缀,
让该门禁从现在起覆盖它们。

同时修正扫描测试的陈旧计数(153→156、edu-contact 26→29、class 18→19,
以 DeclareLeafMetadata 声明数为准),并给 edu-familygroup 第 6 条叶子
manage add-child 补上工具名与整体入参的精确断言。
2026-08-24 18:33:08 +08:00
xiatian fa2d54fd42 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:29:05 +08:00
Dennis c5d58ec49d policy: plan devapp shortcut availability hardening 2026-08-24 18:27:12 +08:00
github-actions[bot] adbc9aac3e Merge pull request #1120 from DingTalk-Real-AI/codex/param-hallucination-8-products
feat(cli): optimize parameter aliases for eight products
2026-08-24 18:25:44 +08:00
xiatian 82a87bf9ce Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:24:13 +08:00
muling.cs 0bfb1ff1de chore: add release fragment for legacy global slot recovery 2026-08-24 18:11:07 +08:00
muling.cs 426ad50927 refresh use legacy global slot 2026-08-24 18:06:05 +08:00
克谨 b57cecb62d feat(cli): optimize eight shortcut parameter aliases 2026-08-24 17:51:45 +08:00
Dennis4477 8dc52d052c Merge pull request #1113 from DingTalk-Real-AI/codex/devapp-availability-compat-governance
policy: add consumable Schema availability migrations
2026-08-24 17:39:07 +08:00
xiatian f8e3f0b12a fix(schema): review float image constraint transition 2026-08-24 17:09:11 +08:00
听荷 aab67202a0 Merge remote-tracking branch 'origin/feat/migrate-edu-contact' into feat/migrate-edu-contact 2026-08-24 17:02:24 +08:00
听荷 2e60b8e121 test: pair confirm-gate and exact-dispatch assertions for all destructive edu leaves
Auto-CR flagged that the destructive-command tests only checked the error
string, so a regression that let a command slip past the confirmation gate
(or dispatch the wrong payload) would go unnoticed.

Every user_required leaf across college-contact (15), edu-contact (2),
edu-group (1) and edu-app (3) is now verified in pairs with a fresh
non-dry-run capture caller: without explicit confirmation the gate must
return confirmation_required AND the caller must see zero calls; with
--yes the command must produce exactly one call carrying the correct
productID, tool name and complete argument payload.
2026-08-24 17:01:57 +08:00
Dennis 6147021ae8 policy: add consumable schema availability migrations 2026-08-24 16:47:19 +08:00
赤川 f84fc1d684 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:46:40 +08:00
赤川 ae31d371de Merge pull request #1114 from DingTalk-Real-AI/codex/fix-policy-timeout-governance
ci: extend Policy job timeout for large Schema surfaces
2026-08-24 16:46:22 +08:00
chichuan c20a53cd8a ci: extend Policy job timeout 2026-08-24 16:43:10 +08:00
xiatian 2dc52afd60 fix(sheet): align float image schema constraints 2026-08-24 16:36:12 +08:00
赤川 21bca3025f Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:07:50 +08:00
听荷 874b5b091a Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 15:58:52 +08:00
github-actions[bot] 1bb56364c2 Merge pull request #1111 from DingTalk-Real-AI/codex/ci-windows-coverage-governance
ci: bound exhaustive coverage test runtimes
2026-08-24 15:54:27 +08:00
赤川 cd93b7fdc2 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 15:34:40 +08:00
克谨 758b0f875e ci: bound exhaustive coverage test runtimes 2026-08-24 15:28:00 +08:00
xiatian fdb5fc19f5 test(sheet): cover float image redirect rejection 2026-08-24 14:48:12 +08:00
xiatian 583d31141d feat(sheet): support local files for float images 2026-08-24 14:48:12 +08:00
github-actions[bot] 19be571574 Merge pull request #1107 from DingTalk-Real-AI/codex/devdoc-hrbrain-pat-shortcuts
feat(shortcut): harden Devdoc HRbrain and PAT surfaces
2026-08-24 14:24:48 +08:00
听荷 38360969e7 style: gofmt college_contact files 2026-08-24 13:36:31 +08:00
听荷 f9277ae1d3 fix: use standard confirmation framework for college-contact destructive commands
Remove custom collegeContactConfirmDestructive function and rely on the
standard framework Confirmation: "user_required" in LeafSpec. This ensures
destructive commands return the proper category:validation/code:3 error
instead of category:internal/code:5.

Also adds run_all_edu_commands_test.go covering all 153 leaf commands
across 5 edu/college products.
2026-08-24 13:30:32 +08:00
听荷 232d9dee6e fix: remove edu products from command matrix to avoid Windows timeout
The command matrix test with 5 additional edu products (153 leaf commands)
exceeds the 10-minute go test timeout on Windows CI runners. The dedicated
TestCrossPlatformCoverage* tests in individual edu test files already
provide 100% changed-code coverage without the matrix.
2026-08-24 13:30:32 +08:00
听荷 c2528f1fc8 fix: add CrossPlatformCoverage prefix to edu tests for platform gate
The CI platform coverage gate (macOS/Windows) only executes tests
matching ^(TestAllShortcuts|TestCrossPlatformCoverage). The edu/college
test functions used standard names and were not exercised during the
platform coverage run, causing 73.89% changed-code coverage (target 100%).

Changes:
- Rename all edu/college test functions with TestCrossPlatformCoverage prefix
- Add edu products to command_matrix_test.go selected map
- Add TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints
  in pkg/edition to cover the new supplement server entries

Local verification: coverage-gate-platform reports 100.0000% (3336 stmts).
2026-08-24 13:30:32 +08:00
听荷 1b22b79fa9 fix: replace --help examples with real execution examples in college-contact
The schema catalog validator requires Examples to demonstrate actual
execution, not just --help. Replace all 65 occurrences with realistic
parameter examples derived from each command's required flags.
2026-08-24 13:30:32 +08:00
听荷 f3e98d55e8 feat: add edu & college vendor extension commands
Add five hidden vendor extension commands for education scenarios:
- edu-contact: school/class/family/teacher contact management
- edu-group: student/class group lifecycle
- edu-app: homework, notices, report cards, diplomas, class circles
- edu-familygroup: family group management, child binding, app permissions
- college-contact: university dept/employee/alumni/graduate management

All route to dedicated MCP servers via callMCPToolOnServer with endpoints
registered in openSupplementServers (helper-only, not VisibleProducts).

Includes: helper implementations, dispatch tests, mono/multi skill docs,
coverage.yaml registration, context-budget compliance, and .changes fragment.
2026-08-24 13:30:32 +08:00
Dennis b5a287ae71 feat(shortcut): harden devdoc hrbrain and pat surfaces 2026-08-24 13:10:38 +08:00
github-actions[bot] da6f867dfa Merge pull request #1085 from typefield/feat/drive-permission-pagination
feat(drive,doc,wiki): permission/member list pagination and multi-type members
2026-08-24 12:17:02 +08:00
zengyouling.zyl 82dc2b5e5e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:57:48 +08:00
zengyouling.zyl 9265fd4cb8 fix(skill): point wiki member pagination at native wiki member list 2026-08-24 11:57:36 +08:00
github-actions[bot] e324ef9d4a Merge pull request #1069 from WHUTzju/feat/add-aitable-datasource-tools
Feat/add aitable datasource tools
2026-08-24 11:33:54 +08:00
zengyouling.zyl fb1847d62e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:15:26 +08:00
zengyouling.zyl 331681e82b ci: retrigger auto-cr to pick up screenshot evidence 2026-08-24 11:05:14 +08:00
陌渊 1633888290 Merge upstream/main: resolve shortcut count conflict + fix field-ids doc
- schemaPublishedShortcutCount: 461→468 after merging aisearch/contact/live
  shortcuts from upstream/main
- Fix P2: datasource-update --field-ids doc says "不传时同步全部字段" but
  actual behavior keeps existing field config; fixed in usage guide and
  reference
2026-08-24 10:54:58 +08:00
github-actions[bot] 206f33ae1c Merge pull request #1083 from DingTalk-Real-AI/codex/shortcut-aisearch-contact-live
feat(shortcut): harden AiSearch Contact and Live task surfaces
2026-08-24 10:44:39 +08:00
陌渊 9259477372 [WP-46-001] fix: update shortcut count constants to match merged sheet/whiteboard shortcuts
publicShortcutCount 422→424, schemaPublishedShortcutCount 460→462,
publiclyDeliveredShortcutCount 422→424
2026-08-24 10:31:32 +08:00
Dennis 137151b38c fix(shortcut): align reviewed live response shapes 2026-08-24 09:57:54 +08:00
陌渊 081220f15e Merge branch 'main' into feat/add-aitable-datasource-tools 2026-08-24 09:50:50 +08:00
Dennis b8216380de fix(aisearch): declare stable result identity 2026-08-23 20:35:11 +08:00
Dennis 83bc213b7f fix(aisearch): reject non-person search sources 2026-08-23 19:40:27 +08:00
Dennis 804b9a3142 fix(contact): normalize exact mobile lookup input 2026-08-23 19:40:26 +08:00
Dennis cfdb0d0556 fix(contact): preserve legacy role placeholders 2026-08-23 19:40:24 +08:00
Dennis d8686122ab fix(schema): reconcile shortcut counts after rebase 2026-08-23 19:40:21 +08:00
Dennis 222a0230ae fix(contact): preserve strict list roles compatibility 2026-08-23 19:40:18 +08:00
Dennis f7befc7943 fix(contact): preserve roster CLI compatibility 2026-08-23 19:40:15 +08:00
Dennis 1dc924af1b fix(contact): align mobile catalog semantics 2026-08-23 19:40:12 +08:00
Dennis 0e8d6e00cf fix(contact): avoid unnecessary mobile detail lookup 2026-08-23 19:40:09 +08:00
Dennis 0028ed1570 fix(contact): restore exact mobile lookup 2026-08-23 19:40:06 +08:00
Dennis 8b5d9a59b0 fix(contact): preserve published schema interface 2026-08-23 19:40:03 +08:00
Dennis 01d663f597 fix(contact): verify exact mobile ownership 2026-08-23 19:40:01 +08:00
Dennis 2bf314c625 fix(contact): preserve list-roles CLI visibility 2026-08-23 19:39:59 +08:00
Dennis 8c98d1abe4 fix(shortcut): harden AiSearch Contact and Live delivery 2026-08-23 19:39:56 +08:00
Dennis 5e4a65513b fix(aisearch): fail closed on unprovable zero results 2026-08-23 19:39:54 +08:00
Dennis 4ae0e0ffc3 fix(contact): close exhaustive shortcut release gate 2026-08-23 19:39:52 +08:00
Dennis 1c9a977d08 fix(shortcut): close residual search and contact gaps 2026-08-23 19:39:50 +08:00
Dennis 78fabec4bb feat(shortcut): fail close Live list task 2026-08-23 19:39:47 +08:00
Dennis 6d6404993a feat(shortcut): harden Contact task surface 2026-08-23 19:39:45 +08:00
Dennis 008d50bb3d feat(shortcut): harden AiSearch task surface 2026-08-23 19:39:40 +08:00
zengyouling.zyl f871689960 ci: retrigger checks after flaky race shard and transient status upload 2026-08-23 19:34:04 +08:00
zengyouling.zyl b15a21de93 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 19:00:08 +08:00
github-actions[bot] 58e8e35948 Merge pull request #1098 from typefield/fix/schema-compat-confirmation-exceptions
fix(ci): review batch remove confirmation hardening
2026-08-23 18:52:41 +08:00
zengyouling.zyl a8b0d8895b fix(ci): review batch remove confirmation hardening
Rebuild the exact-entry reviewedCompatibilityExceptions carve-out in the
base-owned schema-compat checker for the three destructive batch-remove
tools whose confirmation PR #1085 tightens from not_required to
user_required (doc/doc.remove_permission, drive/drive.permission_remove,
wiki/wiki.remove_member). Because the compatibility gate builds its
checker from the PR merge-base, this carve-out has to land on main before
PR #1085 can pass; the entry set is exact (tool + field + old -> new), so
any other confirmation drift, including weakening a reviewed tool back to
not_required, still fails.
2026-08-23 18:33:32 +08:00
zengyouling.zyl 546c2d2eb2 fix(helpers): require user confirmation for batch permission/member remove
Address the P1 review finding on PR #1085: --members lets one call remove
up to 30 USER/DEPT/CONVERSATION/TAG members, where departments, chats,
and role groups can indirectly affect many more users, yet the remove
branches called the MCP tool right after argument parsing with Safety
confirmation=not_required.

- drive permission remove, doc permission remove, and wiki member remove
  now declare confirmation=user_required. DeclareLeafMetadata installs
  the ConfirmSafety gate automatically (deferred to the first
  deps.Caller.CallTool so flag validation still fails first), so an
  unconfirmed invocation exits with the typed confirmation_required
  error and performs zero MCP calls; --yes, an interactive yes, or
  --dry-run previews remain the supported paths.
- Pass framework confirmation errors through WrapErrorWithOperation
  verbatim (new apperrors.IsConfirmationRequired). Text classification
  misrouted them: command paths containing "permission" (drive/doc
  permission remove) were re-reported as AUTH_PERMISSION_DENIED while
  other paths (wiki member remove) lost their reason and degraded to
  UNCLASSIFIED.
- Tests: TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall
  covers all three entry points for both --members and legacy --users —
  unconfirmed rejects with zero MCP calls, --yes dispatches exactly one
  call with the complete precise arguments, --dry-run previews without
  calls. Existing remove tests inject root --yes for the assembly
  assertions; blank --users still fails validation before confirmation.
2026-08-23 17:55:33 +08:00
zengyouling.zyl 5bd0ea7c53 fix(helpers): drop NO_PERMISSION from document permission codes
Address the P2 review finding on PR #1085: NO_PERMISSION is a generic
code name also returned by non-document tools — attendance
get-self-setting (bossAttendStatNotify) and event-subscription attempts
have both been observed returning it — so keying drive permission
apply-* guidance on it would mislead those products, defeating the goal
of the P1 scoping fix. Only the drive-specific forbidden.* domain codes
(forbidden.no.auth / forbidden.accessDenied) and the role-threshold
message wording remain document signals; a bare NO_PERMISSION still
classifies as AUTH_PERMISSION_DENIED but now keeps the product-neutral
suggestion, and NO_PERMISSION combined with document wording still gets
apply guidance.

Add regression tests for the non-document NO_PERMISSION case and update
the changelog fragment; changed-code coverage stays at 100%.
2026-08-23 17:01:49 +08:00
zengyouling.zyl 4833b39071 fix(helpers): scope permission-apply guidance and null->{} rendering to confirmed tools
Address the two P1 review findings on PR #1085:

- Permission suggestions: the drive permission apply-* guidance is now
  limited to document/wiki-specific errors (node access codes
  NO_PERMISSION / forbidden.no.auth / forbidden.accessDenied and the
  role-threshold wording). Permission failures from other products keep
  their product-specific suggestion (e.g. the mail mailbox hint) or fall
  back to a product-neutral hint instead of being told to run document
  permission commands that cannot fix their problem.

- Null rendering: the null->{} adaptation is limited to the four tools
  with a confirmed empty-response-means-success contract
  (update_permission / remove_permission / update_member /
  remove_member). Every other tool keeps its raw null output so the
  shared machine-output contract stays unchanged.

Update tests and the changelog fragment accordingly; changed-code
coverage stays at 100%.
2026-08-23 16:16:34 +08:00
zengyouling.zyl 8a4e49dbf2 test(helpers): cover permission update/remove members and blank --users branches to #1085 2026-08-23 14:34:17 +08:00
zengyouling.zyl 7c924c54ca fix(drive,doc,wiki): register limit mapping exclusion instead of property redirect to #1085
The server rejects the legacy maxResults path; the CLI now validates
--limit (1-50) and sends it as pageSize at runtime. Schema-compat
rejects a non-empty property redirect (maxResults -> pageSize), so
declare --limit as a CLI pagination input via the reviewed mapping
exclusion ledger (property omitted, provenance
reviewed_mapping_exclusion) on doc.list_permission,
drive.list_permission, and wiki.list_member.
2026-08-23 03:04:11 +08:00
zengyouling.zyl 59d0b6dd75 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 02:52:58 +08:00
zengyouling.zyl 74f7bbc980 docs(changes): correct release fragment PR reference to #1085 2026-08-23 02:38:43 +08:00
zengyouling.zyl 2b7d5a2c5f fix(drive,doc,wiki): permission notify default, error guidance, pagination contract to #1065
- --notify now defaults to false and is omitted from the server request
  unless passed explicitly (help updated accordingly)
- forbidden.accessDenied / permission-denied bodies classify as
  AUTH_PERMISSION_DENIED with apply-permission guidance
- user/member validation failures intercepted before RESOURCE_NOT_FOUND
  with --members corpId suggestion
- business error display appends backend code/logId for traceability;
  literal null tool responses render as {}
- drive/doc permission list + wiki member list declare cursor pagination
  (next-token) in Contract; cobra.NoArgs hardening on permission leaves
- cross-platform coverage tests and release fragments updated
2026-08-23 02:25:57 +08:00
github-actions[bot] fcfead71cb Merge pull request #1082 from DingTalk-Real-AI/codex/shortcut-sheet-whiteboard-markdown
feat(shortcuts): harden Sheet Whiteboard and Markdown routes
2026-08-23 01:14:11 +08:00
陌渊 c7510cd1a1 fix(datasource): trim whitespace from batch IDs and fix result/processCode docs
- Add trimNonEmpty for --table-ids in +datasource-sync and --task-ids in
  +datasource-sync-status, matching the existing field-ids pattern
- Add 4 test cases: whitespace-only rejection and trim-through for both
- Fix usage guide: typical workflow and notes no longer equate result
  with processCode; correctly describe result as JSON to parse for
  approvals[].processCode/name/iconUrl/url
2026-08-21 16:19:43 +08:00
john 1c3477c087 Merge branch 'main' into feat/drive-permission-pagination 2026-08-21 16:18:15 +08:00
陌渊 93d450a9bf fix(datasource): read --field-ids as string slice, not string
--field-ids is declared as FlagStringSlice, but DatasourceCreate and
DatasourceUpdate previously called rt.Str to check whether the flag
was empty. RuntimeContext.Str delegates to cobra's GetString, which
returns an empty string on slice-typed flags, so the empty-value
guard rejected every explicit --field-ids input and the downstream
MCP tool never received fieldIds.

Switch to rt.StrSlice, sanitize through a new trimNonEmpty helper
(drop whitespace-only / empty entries) and pass the cleaned slice
to MCP. Add success-passthrough tests for both create and update,
plus a whitespace-only rejection case, and enhance the mock caller
to record MCP arguments so fieldIds can be asserted.
2026-08-21 16:10:23 +08:00
陌渊 cf39768095 fix(datasource): align field-ids semantics and test naming for coverage gate
- Update --field-ids description in create/update shortcuts and the
  helper-layer datasource update to clarify that omitting the flag
  keeps existing config (create defaults to all fields), matching the
  actual update overwrite semantics.
- Rename datasource shortcut coverage tests to the
  TestCrossPlatformCoverage* prefix so they are picked up by the
  macOS platform coverage gate.
2026-08-21 16:10:20 +08:00
陌渊 c096258b0f fix(datasource): reject empty field-ids and auto-sync-setting in shortcut layer
Align shortcut layer validation with helper layer to prevent empty slices
from being sent to MCP, which could clear sync field selection due to
datasource update's overwrite semantics.

- Add empty string checks for --field-ids in both create and update shortcuts
- Add empty string checks for --auto-sync-setting in both create and update shortcuts
- Add regression tests verifying MCP is not called when empty values are rejected
- Both public entry points now have consistent validation behavior

Fixes P1 auto-CR issue for empty flag bypass vulnerability.
2026-08-21 16:10:18 +08:00
陌渊 5ba8ac6775 test(aitable): cover datasource shortcut and helper error paths for 100% changed-code coverage 2026-08-21 16:10:15 +08:00
陌渊 66fee5ef5f fix(aitable): update shortcut counts after rebase onto upstream main 2026-08-21 16:10:12 +08:00
陌渊 d9b9c5c7da fix(aitable): reject empty field-ids/auto-sync-setting and non-object JSON 2026-08-21 16:10:09 +08:00
陌渊 684411e54e fix(aitable): require task-ids for datasource sync-status and align docs
Make +datasource-sync-status consistent across shortcut and native
commands: --task-ids is now required, descriptions focus on querying
by taskId, and optional/IDLE semantics are removed. Update tests,
usage guide, reference doc, and SKILL description accordingly.
2026-08-21 16:10:05 +08:00
陌渊 de5ba029d4 fix(aitable): add field-ids/auto-sync-setting to native datasource create/update
Native datasource create/update now expose --field-ids and
--auto-sync-setting, matching the shortcut-layer capabilities:
- flags registered on both commands
- Contract Parameters updated
- values mapped to MCP tool args
- JSON validation for --auto-sync-setting
- no-change update guard now counts the new flags

Also fixes the missing required name in the usage-guide update example.
2026-08-21 16:10:02 +08:00
陌渊 15f139e32d fix(aitable): reject no-change datasource update and fix doc example
+datasource-update now requires at least one mutable option
(--source-config, --auto, --field-ids, or --auto-sync-setting)
before calling update_datasource_config, preventing accidental
sync triggers. The native datasource update command enforces the
same guard for its supported flags. Also adds the required name
field to the +datasource-get-fields doc example.
2026-08-21 16:09:58 +08:00
陌渊 768c1ce494 fix(aitable): only send --auto on datasource-update when explicitly set
Omitting --auto on +datasource-update previously sent auto=false to
MCP, silently disabling auto-sync for existing datasources. Now auto
is only included in tool args when the flag is explicitly provided,
so --auto=true and --auto=false work while omission preserves the
existing setting. Updated flag descriptions and added tests.
2026-08-21 16:09:55 +08:00
陌渊 966fd60e2f fix(aitable): always send auto=false for datasource create/update
MCP requires the auto field in create_datasource / update_datasource_config
requests. Previously CLI only sent it when --auto was explicitly changed,
causing failures when users omitted the flag. Now both shortcut and helper
layers always include auto=false by default.

Also update flag descriptions and docs to clarify that the field is always
sent downstream, and add test assertions for the default-false behavior.
2026-08-21 16:09:51 +08:00
陌渊 7825c3c7e0 docs(aitable): fix datasource doc inconsistencies for auto CR P2
- docs/datasource-usage-guide.md: clarify that list-sources result is a
  JSON string containing approvals[]; add missing --auto-sync-setting
  parameter table rows and a dedicated autoSyncSetting format section
  using the correct scheduled/daily/weekly/monthly enums.
- skills/references/aitable/aitable-datasource.md: fix autoSyncSetting
  enums (schedule/day/week/month -> scheduled/daily/weekly/monthly) and
  update the create example accordingly.
2026-08-21 16:09:49 +08:00
陌渊 10d44615d1 fix(aitable): include required name in datasource source-config examples
The OA approval source-config contract requires processCode, name,
iconUrl, and url to be passed through unchanged from +datasource-list-sources.
Published examples for +datasource-create, +datasource-update, and
+datasource-get-fields were missing `name`, and the usage guide marked it
as optional. Fix all examples in the shortcut layer, helper layer, and
docs; update flag descriptions to mention name; and add a contract test
that validates every delivered example's source-config JSON contains the
required members.
2026-08-21 16:09:39 +08:00
陌渊 1e88612e43 test(aitable): add datasource helper tests for 100% changed-code coverage
21 tests covering all 7 datasource leaf commands' error paths (missing
required flags, count validation) and happy paths (source-config as raw
string, --auto flag, boundary cases for table-ids/task-ids).
2026-08-21 16:09:36 +08:00
陌渊 5934ccac7f fix(aitable): enforce 1-5 count limit on table-ids and task-ids
Both the shortcut (+datasource-sync, +datasource-sync-status) and
helper (datasource sync, datasource sync-status) layers now validate
that table-ids contains 1-5 IDs and task-ids contains at most 5 IDs
before calling MCP, matching the declared contract.
2026-08-21 16:09:30 +08:00
陌渊 d9365f3fff docs: remove unimplemented --conflict-strategy from all datasource docs 2026-08-21 16:09:27 +08:00
陌渊 15d93698bd fix(aitable): use String instead of StringSlice for datasource flags
ValidateRequiredFlags calls GetString which returns empty for
StringSlice flags, causing the examples test to report --table-ids
as missing. Switch to String + parseCSVValues to match the codebase
convention used by record-ids and other comma-separated flags.
2026-08-21 16:09:23 +08:00
陌渊 7e69a3d6fe fix(aitable): add datasource helper leaf commands and fix CI test counts
- Add 7 datasource leaf commands to internal/helpers/aitable.go so
  coverage test can find tool name literals (fixes TestAllShortcutsAssemble)
- Add 7 entries to semantic_catalog_aitable.json and update catalog count
  from 93 to 100 (fixes TestCrossPlatformCoverageAITableSemanticCatalog)
- Update publicShortcutCount/schemaPublishedShortcutCount/publiclyDelivered
  from 422/447/422 to 429/454/429 (fixes TestDeliverySchemaCoversOrExactly)
- Fix Contract.Selection.AgentSummary and UseWhen[0] in datasource.go to
  match Description and Intent exactly as required by schema contract test
2026-08-21 16:09:21 +08:00
陌渊 94b4958038 chore(aitable): regenerate SKILL.md shortcut section via gen_skill_shortcut_sections.py 2026-08-21 16:09:19 +08:00
陌渊 97a99ba04f style: fix gofmt indentation in datasource.go 2026-08-21 16:09:17 +08:00
陌渊 5e1e5cbb84 fix(aitable): remove duplicate datasource-get-fields and datasource-list-sources rows in SKILL.md 2026-08-21 16:09:14 +08:00
陌渊 e04e886c50 chore: add release fragment for aitable datasource shortcuts 2026-08-21 16:09:12 +08:00
陌渊 1acde9b766 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-006]
- Fix autoSyncSetting enum: scheduled/daily/weekly/monthly; mark
  selectedMonthDays/selectedWeekdays as required for monthly/weekly
- Remove splitParentTableField from --source-config user-settable fields;
  add note that splitParentTableField/enableDataSyncOaDetailList are
  internal downstream fields not to be passed
- Prepend sync-is-fire-and-forget notice to DatasourceSync descriptions
- Remove --conflict-strategy flag (syncConflictStrategy not in MCP schema)
2026-08-21 16:09:09 +08:00
陌渊 852efe56aa feat(aitable): add datasource skill optimization
- Golden Route: add datasource entry (list-sources → create flow)
- 常用 leaf 直达: add datasource-* commands
- 当前最短路径: add list-sources-first rule
- 安全边界: add sync write warning
- 错误最短路径: add errorCode=4014 and sync=false handling
- 按需加载: add datasource reference trigger
- New reference: aitable-datasource.md with full workflow, sourceConfig
  protocol, autoSyncSetting config, command details, error codes
2026-08-21 16:09:05 +08:00
陌渊 6c287bcb4d feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-005]
- Add --auto-sync-setting flag to DatasourceCreate (was only in Execute, not in Flags)
- Expand DatasourceSync description: add 文档链接, errorCode=4014 幂等冲突, 非数据源表参数错误
- Simplify DatasourceGetFields description: remove field property enumeration to match snapshot
2026-08-21 16:09:00 +08:00
陌渊 df24d53886 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-004]
Sync CLI field descriptions with latest ai-table-mcp-snapshot.json:
- source-config flags: restructure to "两类字段" (4 passthrough + caller-set),
  add splitParentTableField, fix Update flag to optional semantics
- get_datasource_sync_status: update status list (RUNNING/FINISHED/FAILED,
  remove TIMEOUT), change "不传返回最近一次" → "IDLE(下游暂不支持)"
- get_datasource_config: add sync=true guard note, "其他类型暂不支持", sourceConfig hint
- list_datasource_sources: full rewrite explaining result/approvals structure,
  4-field passthrough rule, enableDataSyncOaDetailList internal note
- get_datasource_fields: add "其他数据源类型暂不支持,待后续开放"
2026-08-21 16:08:50 +08:00
陌渊 49cecabb12 [WP-40-003] feat: align 7 datasource shortcuts with latest MCP snapshot
- Add --auto-sync-setting flag (JSON string) to +datasource-create and
  +datasource-update, validated and passed through as raw string.
- Update +datasource-update --source-config desc to reflect full
  replacement semantics ("传入时整体覆盖") and spell out required /
  optional fields with defaults.
- Append "仅支持 OA 审批数据源 (datasourceType=OA)" to
  +datasource-get-config description.
- Simplify +datasource-list-sources / +datasource-get-fields
  descriptions to concise Chinese aligned with snapshot wording.
- Update SKILL.md shortcuts table and add datasource usage guide.
2026-08-21 16:08:45 +08:00
陌渊 09993ad82c [WP-40-002] fix: correct idempotency value from not_idempotent to non_idempotent 2026-08-21 16:08:42 +08:00
陌渊 0efaf6c82f [WP-40-002] feat: update SKILL.md with 5 datasource shortcuts and trigger words 2026-08-21 16:08:39 +08:00
陌渊 26002637f4 [WP-40-001] feat: add 5 datasource shortcuts for aitable
Add 5 data source sync management shortcuts to the aitable service:
- +datasource-create (create_datasource): create sync config + first sync
- +datasource-update (update_datasource_config): update existing sync config
- +datasource-sync (run_datasource_sync): trigger manual sync (max 5 tables)
- +datasource-sync-status (get_datasource_sync_status): query sync task status
- +datasource-get-config (get_datasource_config): get sync config details

Each shortcut declares a full Contract (Identity/Interface/Selection),
Safety, Flags, and Execute that calls rt.CallMCPData on the "aitable"
MCP server. datasource-type is passed through without CLI enum check;
source-config is validated as a JSON object via parseJSONObject.
2026-08-21 16:08:35 +08:00
玉澜 d552c59d11 feat(drive,doc,wiki): permission/member list pagination and multi-type members
Sync the permission CRUD overhaul from the internal CLI (MR 28965577):

- drive/doc permission list and wiki member list now accept --next-token
  to follow the server cursor (totalCount/hasMore/nextToken); --limit maps
  to pageSize capped at 50 instead of the rejected maxResults=200 path
  (fixes #1065)
- permission add/update/remove and wiki member add/update/remove accept a
  --members JSON array (USER/DEPT/CONVERSATION/TAG grantee types, each with
  its own roleId) with optional --notify; legacy --users/--role stays
- cursor/page-token hidden cross-product aliases now resolve to next-token
- regenerate param_aliases_generated.go; wiki member list override no
  longer blocks cursor
- update mono/multi skill references and add change fragment
2026-08-21 11:30:22 +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
252 changed files with 43230 additions and 2465 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.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
@@ -0,0 +1,24 @@
---
category: Changed
---
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
@@ -0,0 +1,22 @@
---
category: Added
---
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
@@ -0,0 +1,5 @@
---
category: Added
---
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
+15 -5
View File
@@ -544,7 +544,8 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-r
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -692,7 +693,8 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-r
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -1100,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
@@ -1323,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")"
@@ -1597,7 +1604,10 @@ jobs:
name: Policy
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
# Full policy regenerates and validates the runtime Schema several times.
# Keep job-level headroom for large reviewed command-surface additions;
# individual policy gates retain their own fail-closed checks.
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
+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
+80
View File
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.2] - 2026-08-24
### Added
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
### Changed
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
### Fixed
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
## [1.0.60-beta.1] - 2026-08-21
### Changed
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.60-beta.1"
version "1.0.60-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
end
def install
+6 -2
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -20,6 +20,7 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
shortcut-public-e2e-proof: build
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
lint:
@./scripts/dev/lint.sh
@@ -84,7 +88,7 @@ fmt:
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy: test-auth-legacy-compat
policy: test-auth-legacy-compat shortcut-public-e2e-proof
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
+454
View File
@@ -0,0 +1,454 @@
# AI 表格数据源指令使用指南
## 概述
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
## 指令速览
| 指令 | 用途 | 读写 | 风险 |
|------|------|------|------|
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
## 前置条件
1. **登录认证**:执行 `dws auth login` 确保已登录
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
---
## 1. 列出数据源可用来源
```
dws aitable +datasource-list-sources [flags]
```
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
### 示例
```bash
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
```
### 返回值
返回 `sources` 数组,每个条目包含:
| 字段 | 说明 |
|------|------|
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
| `sourceUrl` | 数据源访问链接,可选 |
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
---
## 2. 获取数据源可同步字段
```
dws aitable +datasource-get-fields [flags]
```
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
### 示例
```bash
# 获取某审批模板的可同步字段
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
```
### 返回值
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
---
## 3. 创建数据源表
```
dws aitable +datasource-create [flags]
```
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
### source-config 格式(审批类)
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
按 `dataType` 选择对应的时间参数组合:
| dataType | 需要的时间字段 | 说明 |
|----------|----------------|------|
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
| `start_time` | `startDate` | 同步从某日期至今的数据 |
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
### auto-sync-setting 格式
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
### 示例
```bash
# 基本创建——同步近 30 天审批数据
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 指定日期范围创建并开启自动同步
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--auto
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--field-ids fldAAA,fldBBB,fldCCC
```
### 返回值
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
---
## 4. 更新数据源配置
```
dws aitable +datasource-update [flags]
```
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
### 示例
```bash
# 更换审批模板并调整时间范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 更新同步字段范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--field-ids fldAAA,fldDDD
```
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
---
## 5. 触发手动同步
```
dws aitable +datasource-sync [flags]
```
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
### 示例
```bash
# 同步单张表
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1
# 批量同步多张表(逗号分隔,最多 5 个)
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1,TBL2,TBL3
```
### 返回值
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
---
## 6. 查询同步状态
```
dws aitable +datasource-sync-status [flags]
```
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
### 示例
```bash
# 按任务 ID 查询(批量,最多 5 个)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK1,TASK2
```
---
## 7. 获取数据源配置
```
dws aitable +datasource-get-config [flags]
```
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
### 示例
```bash
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
---
## 典型工作流
### 场景一:从零接入审批数据
```bash
# 0. 获取 Base ID
dws aitable +base-search --query "我的项目表"
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
# 2. 查看可同步字段(可选,用于指定 field-ids)
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 3. 创建数据源表(创建后自动触发首次同步)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# → 返回 tableId=TBL456, taskId=TASK001
# 4. 查询首次同步是否完成
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
# 5. 确认配置
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
### 场景二:更换审批模板后重新同步
```bash
# 1. 更新源配置(更新后自动触发一次同步)
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 2. 查询同步状态(更新后会返回新的 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK002
```
### 场景三:手动触发日常同步
```bash
# 仅触发同步,不修改配置
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL456
# 查询结果(sync 会返回 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
```
### 场景四:开启自动同步后确认
```bash
# 1. 更新配置,开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 2. 确认配置已更新
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
# → 返回中应显示 auto=true
```
---
## 通用选项
以下全局选项可在所有指令中使用:
| 选项 | 说明 |
|------|------|
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
| `--fields` | 筛选输出字段(逗号分隔) |
| `--dry-run` | 预览操作内容,不实际执行 |
| `--profile` | 指定组织或账号 |
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
| `--debug` | 显示调试日志 |
| `-v, --verbose` | 显示详细日志 |
### 输出过滤示例
```bash
# 只取 tableId
dws aitable +datasource-create ... --jq '.tableId'
# 只取同步状态
dws aitable +datasource-sync-status ... --jq '.status'
# table 格式查看
dws aitable +datasource-get-config ... -f table
```
---
## 注意事项
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
+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` 语义;不是互相否定):
+364 -75
View File
@@ -1,7 +1,67 @@
{
"generated_at": "2026-08-21T10:07:48.251313",
"count": 418,
"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",
"command": "+search-person",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -272,6 +332,76 @@
"semantic_delta": "更新仪表盘配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-config",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-fields",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-list-sources",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync-status",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -2263,235 +2393,384 @@
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+by-mobile",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+dept-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-dept-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-followings",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-role-members",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"service": "contact",
"command": "+list-roles",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+list-sub-depts",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+lookup",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+me",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+org",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+resolve-dept",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+search-mobile",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+search-user",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "contact",
"command": "+team",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
"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",
@@ -3593,6 +3872,16 @@
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"availability": "available"
},
{
"suite": "semantic",
"service": "pat",
"command": "+browser-policy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
+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
}
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
@@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
@@ -231,12 +231,113 @@ var paramAliasCompleteCommands = map[string][]string{
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// paramAliasCandidateCompleteCommands contains complete invocations for
// reviewed parameter-concept product drafts. Keeping candidate-only commands
// in a separate map lets a test change land before a draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
"contact +org": {"contact", "+org", "--name", "Fixture User"},
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
"contact +team": {"contact", "+team", "--name", "Fixture User"},
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
@@ -296,6 +397,20 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"markdown create": {
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
},
"markdown diff": {
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
},
"markdown overwrite": {
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
},
"markdown patch": {
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
},
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
@@ -969,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
}
}
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
// exercises every distinct reviewed mutating complete-command template in the
// reviewed product expansions. The fixture gate already proves every
// alias resolves through PreParse; this gate removes the confirmation flag
// from one active alias invocation per distinct template and requires the
// runtime boundary to stop it before the first transport call. An explicit
// --dry-run is a reviewed preview path and must not carry a bypass flag.
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
requiredTemplates := make(map[string]bool)
coveredTemplates := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
default:
continue
}
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
continue
}
_, yesCount := removeExactArg(complete, "--yes")
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
confirmationCount := yesCount + userSayYesCount
confirmationArg := "--yes"
if userSayYesCount == 1 {
confirmationArg = "--user-say-yes"
}
_, dryRunCount := removeExactArg(complete, "--dry-run")
if dryRunCount > 1 {
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
continue
}
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
switch meta.Safety.Confirmation {
case "user_required":
if dryRunCount == 1 {
if confirmationCount != 0 {
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
}
continue
}
if confirmationCount != 1 {
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
case "not_required":
if confirmationCount != 0 {
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
}
}
if confirmationCount == 0 {
continue
}
if confirmationCount != 1 {
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
continue
}
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
requiredTemplates[templateKey] = true
if coveredTemplates[templateKey] {
continue
}
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
continue
}
coveredTemplates[templateKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
})
}
if len(requiredTemplates) == 0 {
t.Fatal("reviewed complete-command templates contain no confirmation cases")
}
if len(coveredTemplates) != len(requiredTemplates) {
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
}
}
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
if removals != 1 {
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed alias command skipped PreParse")
}
var appErr *apperrors.Error
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
return
}
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
+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)
}
}
+2
View File
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
@@ -0,0 +1,70 @@
package app
import (
"testing"
)
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"sheet.create_float_image",
"sheet.update_float_image",
)
create := payload.Tools["sheet.create_float_image"]
if create == nil {
t.Fatal("missing sheet.create_float_image")
}
if create["interface_mode"] != "mcp" {
t.Fatalf("create interface mode = %#v", create["interface_mode"])
}
createRef, _ := create["interface_ref"].(map[string]any)
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
t.Fatalf("create interface ref = %#v", createRef)
}
createDryRun, _ := create["dry_run"].(map[string]any)
if createDryRun["preview_kind"] != "request" {
t.Fatalf("create dry-run = %#v", createDryRun)
}
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
}
createParameters, _ := create["parameters"].(map[string]any)
file, _ := createParameters["file"].(map[string]any)
src, _ := createParameters["src"].(map[string]any)
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
t.Fatalf("create --file metadata = %#v", file)
}
if schemaContractString(file["property"]) != "" {
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
}
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
t.Fatalf("create --src compatibility metadata = %#v", src)
}
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
update := payload.Tools["sheet.update_float_image"]
if update == nil {
t.Fatal("missing sheet.update_float_image")
}
updateDryRun, _ := update["dry_run"].(map[string]any)
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
}
updateParameters, _ := update["parameters"].(map[string]any)
updateFile, _ := updateParameters["file"].(map[string]any)
if schemaContractString(updateFile["property"]) != "" {
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
}
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
root := NewRootCommand()
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
command := exactCommandForTest(root, cliPath)
if command == nil || command.Flags().Lookup("file") == nil {
t.Fatalf("%s has no executable --file flag", cliPath)
}
}
}
+76 -3
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 417
publicShortcutCount = 436
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 455
schemaPublishedShortcutCount = 493
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 417
publiclyDeliveredShortcutCount = 436
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -271,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
}
}
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
continue
}
if !strings.HasPrefix(declared.Command, "+datasource-") {
continue
}
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
continue
}
checked++
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("delivery schema --all is missing %s", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 {
t.Fatalf("%s has no delivered examples", canonical)
}
for _, example := range examples {
if !strings.Contains(example, "--source-config") {
continue
}
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
sourceConfig := schemaExampleFlagValue(argv, "source-config")
if sourceConfig == "" {
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
continue
}
var cfg map[string]any
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
continue
}
for _, member := range requiredSourceConfigMembers {
if _, ok := cfg[member]; !ok {
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
}
}
}
}
if checked != 3 {
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
@@ -282,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
return false
}
func schemaExampleFlagValue(argv []string, name string) string {
prefix := "--" + name + "="
for _, argument := range argv {
if argument == "--"+name {
continue
}
if strings.HasPrefix(argument, prefix) {
return strings.TrimPrefix(argument, prefix)
}
}
// Value may be in the next argv entry: `--flag value` form.
for i := 0; i < len(argv)-1; i++ {
if argv[i] == "--"+name {
return argv[i+1]
}
}
return ""
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
+47 -8
View File
@@ -20,18 +20,50 @@ import (
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
message string
reason string
origin string
stage string
hint string
actions []string
operation string
retryable *bool
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
combined := text + " " + detail
if code == "999" &&
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
classified := serverFailureClass{
message: message,
reason: "upstream_internal_error",
origin: "dingtalk_api",
stage: "upstream_execution",
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
actions: []string{
"检查目标资源的当前状态,确认本次操作是否已经生效",
"状态未确认前不要直接重试写操作",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
},
}
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
retryable := false
classified.operation = "todo/create_personal_todo"
classified.retryable = &retryable
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
classified.actions = []string{
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
"确认没有创建成功后再重新提交",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
}
}
return classified, true
}
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
fallbackReason string,
fallbackHint string,
serverKey string,
tool string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
if classified.operation != "" {
opts = append(opts, apperrors.WithOperation(classified.operation))
}
if classified.retryable != nil {
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
}
}
return apperrors.NewAPI(message, opts...)
}
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
"business_error",
"check parameters",
"chat",
"send_message",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
}
}
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
serverSaysRetryable := true
err := newServerFailureAPIError(
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
"business_error",
"The API returned a business-level error. Check required parameters and values.",
"todo",
"create_personal_todo",
apperrors.ServerDiagnostics{
TraceID: "trace-todo-create",
ServerErrorCode: "999",
ServerRetryable: &serverSaysRetryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.Operation != "todo/create_personal_todo" {
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
}
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
t.Fatalf("hint = %q", typed.Hint)
}
for _, action := range typed.Actions {
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
t.Fatalf("misleading action = %q", action)
}
}
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "upstream_internal_error",
"origin": "dingtalk_api",
"stage": "upstream_execution",
"retryable": false,
"trace_id": "trace-todo-create",
"server_error_code": "999",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "999",
"trace_id": "trace-todo-replay",
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "todo",
Tool: "create_personal_todo",
CanonicalPath: "todo.create_personal_todo",
Params: map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": "fixture",
"executorIds": []string{"user-1"},
},
},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
}
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
}
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
}
+123
View File
@@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
// The organization mirror may be absent for long-lived local logins
// that predate mirror publication. Recover from the legacy global
// slot before giving up.
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
return recovered, nil
}
return nil, rErr
}
if p.logger != nil {
@@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
return refreshed, nil
}
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
var exchangeErr *MCPTokenExchangeError
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
return nil, refreshErr
}
if selected == nil {
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
"corp_id", strings.TrimSpace(selected.CorpID),
"user_id", strings.TrimSpace(selected.UserID),
"refresh_error_code", exchangeErr.Code,
)
legacy, loadErr := tokenLoadKeychain()
if loadErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
return nil, refreshErr
}
if legacy == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
return nil, refreshErr
}
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
"step", "candidate_mismatch",
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
"legacy_user_id", strings.TrimSpace(legacy.UserID),
)
return nil, refreshErr
}
recovered := *legacy
if strings.TrimSpace(recovered.UserID) == "" {
recovered.UserID = strings.TrimSpace(selected.UserID)
}
if strings.TrimSpace(recovered.UserName) == "" {
recovered.UserName = strings.TrimSpace(selected.UserName)
}
if recovered.IsAccessTokenValid() {
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
return &recovered, nil
}
if !recovered.IsRefreshTokenValid() {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
return nil, refreshErr
}
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
return nil, refreshErr
}
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
return nil, refreshErr
}
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
if recoverErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
return refreshed, nil
}
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
if selected == nil || legacy == nil {
return false
}
selectedCorpID := strings.TrimSpace(selected.CorpID)
legacyCorpID := strings.TrimSpace(legacy.CorpID)
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
return false
}
selectedUserID := strings.TrimSpace(selected.UserID)
legacyUserID := strings.TrimSpace(legacy.UserID)
if legacyUserID != "" {
return legacyUserID == selectedUserID
}
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
}
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
if strings.TrimSpace(corpID) == "" {
return false
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil || cfg == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
return false
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "multi_profile",
"corp_id", strings.TrimSpace(corpID),
"profile_count", len(profiles),
)
return false
}
profile := profiles[0]
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
return true
}
profileUserID := ""
if profile != nil {
profileUserID = strings.TrimSpace(profile.UserID)
}
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "identity_mismatch",
"selected_user_id", strings.TrimSpace(userID),
"profile_user_id", profileUserID,
)
return false
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
+517
View File
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "V1039 User",
CorpID: selected.CorpID,
UserID: selected.UserID,
UserName: selected.UserName,
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
var saved *TokenData
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
copy := *data
saved = &copy
return nil
})
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
}
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
}
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-multi",
UserID: "user-v1039-a",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-blank-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-user-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: "user-v1039-other",
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token owned by a different user")
}
}
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
expired := *fixture.blankToken
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
}
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-single-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "Other User",
CorpID: selected.CorpID,
UserID: "user-v1039-other",
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-legacy-refresh",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
refreshed := &TokenData{
AccessToken: "refreshed-legacy-access",
RefreshToken: "rotated-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
refreshCalls := 0
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls++
if refreshCalls == 1 {
return nil, rejection
}
return refreshed, nil
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if refreshCalls != 2 {
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
}
if recovered.AccessToken != refreshed.AccessToken {
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
}
if recovered.RefreshToken != refreshed.RefreshToken {
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-profiles-error",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "shared-rejected-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-same-refresh",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: selected.RefreshToken,
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
provider := NewOAuthProvider(t.TempDir(), nil)
// nil selected must be rejected before any dereference.
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil selected error = %v, want original rejection", err)
}
// A keychain load that returns (nil, nil) must be rejected before any dereference.
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
selected := &TokenData{
CorpID: "corp-v1039-nil-legacy",
UserID: "user-v1039",
Source: "mcp",
}
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil legacy error = %v, want original rejection", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
provider := NewOAuthProvider(t.TempDir(), nil)
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
plainErr := errors.New("plain refresh failure")
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
t.Fatalf("plain error = %v, want original plain failure", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
selected := &TokenData{
CorpID: "corp-v1039-recovery-steps",
UserID: "user-v1039",
Source: "mcp",
}
t.Run("save_failure", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "valid-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("save failure error = %v, want original rejection", err)
}
})
t.Run("refresh_expired", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "expired-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(-time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("expired refresh error = %v, want original rejection", err)
}
})
t.Run("refresh_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, errors.New("legacy refresh failed")
})
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("legacy refresh error = %v, want original rejection", err)
}
})
t.Run("preflight_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("preflight error = %v, want original rejection", err)
}
})
}
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
configDir := t.TempDir()
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
t.Fatal("nil legacy accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
t.Fatal("different corp accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
t.Fatal("blank selected corp accepted")
}
blankSelected := &TokenData{CorpID: selected.CorpID}
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
})
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
t.Fatal("both blank user IDs should match only through the single-profile guard")
}
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
t.Fatal("blank selected with non-blank legacy accepted")
}
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
t.Fatal("blank corp accepted by single-profile check")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -96,7 +96,14 @@ func init() {
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src"}},
})
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
})
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
registerRequireOneOf("sheet.import", "folder-token", "workspace")
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
@@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
+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)
}
})
}
}
+18
View File
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
}
}
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
// typed framework confirmation-gate failure carrying reason
// confirmation_required. Downstream classifiers must pass such errors through
// verbatim: the "re-run with --yes" semantics can only be carried by the
// machine-readable reason, while message-text classification actively
// misroutes them (a command path containing "permission" would be reported as
// an auth failure, and any other wording degrades to an unclassified error).
func IsConfirmationRequired(err error) bool {
if err == nil {
return false
}
var typed *Error
if stderrors.As(err, &typed) {
return strings.TrimSpace(typed.Reason) == "confirmation_required"
}
return false
}
// WithServerKey records the server identifier associated with the failure.
func WithServerKey(serverKey string) Option {
return func(err *Error) {
+32
View File
@@ -15,6 +15,7 @@ package errors
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
t.Parallel()
if IsConfirmationRequired(nil) {
t.Fatal("nil error must not report confirmation_required")
}
if IsConfirmationRequired(NewValidation("missing required flag")) {
t.Fatal("plain validation error must not report confirmation_required")
}
plain := stderrors.New("需要用户确认")
if IsConfirmationRequired(plain) {
t.Fatal("message text alone must not report confirmation_required")
}
confirmation := NewValidation(
"blocked",
WithReason("confirmation_required"),
)
if !IsConfirmationRequired(confirmation) {
t.Fatal("typed confirmation error must report confirmation_required")
}
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
wrapped := fmt.Errorf("call tool: %w", confirmation)
if !IsConfirmationRequired(wrapped) {
t.Fatal("wrapped confirmation error must report confirmation_required")
}
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
if IsConfirmationRequired(otherReason) {
t.Fatal("other reasons must not report confirmation_required")
}
}
+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 自己能解析
+436 -23
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",
@@ -8422,6 +8424,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
sectionMoveNodeCmd,
)
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
Short: "获取数据源表同步配置",
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_config", map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
})
},
}
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_config",
CanonicalPath: "aitable.datasource_get_config",
CLIPath: "aitable datasource get-config",
PrimaryCLIPath: "aitable datasource get-config",
},
Description: "获取数据源表的同步配置信息。",
Interface: aitableMCPInterface("get_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "获取数据源表的同步配置信息。",
UseWhen: []string{"查看已有数据源表的配置详情时"},
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
},
},
})
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceListSourcesCmd := &cobra.Command{
Use: "list-sources",
Short: "列出可用数据源来源",
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("list_datasource_sources", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
})
},
}
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_list_sources",
CanonicalPath: "aitable.datasource_list_sources",
CLIPath: "aitable datasource list-sources",
PrimaryCLIPath: "aitable datasource list-sources",
},
Description: "列出指定 Base 下可用的数据源条目。",
Interface: aitableMCPInterface("list_datasource_sources"),
Selection: contract.SelectionSpec{
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
},
},
})
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
validateJSONObject := func(flag, raw string) error {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
}
if _, ok := v.(map[string]any); !ok {
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
}
return nil
}
validateAutoSyncSetting := func(raw string) error {
return validateJSONObject("auto-sync-setting", raw)
}
datasourceGetFieldsCmd := &cobra.Command{
Use: "get-fields",
Short: "获取数据源可同步字段列表",
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_fields", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
})
},
}
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_fields",
CanonicalPath: "aitable.datasource_get_fields",
CLIPath: "aitable datasource get-fields",
PrimaryCLIPath: "aitable datasource get-fields",
},
Description: "获取指定数据源来源的可同步字段列表。",
Interface: aitableMCPInterface("get_datasource_fields"),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
},
})
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
datasourceCreateCmd := &cobra.Command{
Use: "create",
Short: "创建数据源表并触发首次同步",
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
auto, _ := cmd.Flags().GetBool("auto")
toolArgs := map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
"auto": auto,
}
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
toolArgs["fieldIds"] = parseCSVValues(v)
}
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
return callAitableTool("create_datasource", toolArgs)
},
}
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_create",
CanonicalPath: "aitable.datasource_create",
CLIPath: "aitable datasource create",
PrimaryCLIPath: "aitable datasource create",
},
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
Interface: aitableMCPInterface("create_datasource"),
Selection: contract.SelectionSpec{
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新数据源表同步配置并触发同步",
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
}
if cmd.Flags().Changed("source-config") {
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
}
if cmd.Flags().Changed("auto") {
auto, _ := cmd.Flags().GetBool("auto")
toolArgs["auto"] = auto
}
if cmd.Flags().Changed("field-ids") {
v := mustGetFlag(cmd, "field-ids")
if v == "" {
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
}
toolArgs["fieldIds"] = parseCSVValues(v)
}
if cmd.Flags().Changed("auto-sync-setting") {
v := mustGetFlag(cmd, "auto-sync-setting")
if v == "" {
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
}
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
}
return callAitableTool("update_datasource_config", toolArgs)
},
}
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_update",
CanonicalPath: "aitable.datasource_update",
CLIPath: "aitable datasource update",
PrimaryCLIPath: "aitable datasource update",
},
Description: "更新已有数据源表的同步配置并触发一次同步。",
Interface: aitableMCPInterface("update_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
Examples: []string{
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig"},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceSyncCmd := &cobra.Command{
Use: "sync",
Short: "触发数据源表手动同步",
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
if len(tableIDs) < 1 || len(tableIDs) > 5 {
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
}
return callAitableTool("run_datasource_sync", map[string]any{
"baseId": baseID,
"tableIds": tableIDs,
})
},
}
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync",
CanonicalPath: "aitable.datasource_sync",
CLIPath: "aitable datasource sync",
PrimaryCLIPath: "aitable datasource sync",
},
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
Interface: aitableMCPInterface("run_datasource_sync"),
Selection: contract.SelectionSpec{
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
},
},
})
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceSyncStatusCmd := &cobra.Command{
Use: "sync-status",
Short: "按任务 ID 查询数据源同步任务状态",
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
if len(ids) < 1 || len(ids) > 5 {
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"taskIds": ids,
}
return callAitableTool("get_datasource_sync_status", toolArgs)
},
}
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync_status",
CanonicalPath: "aitable.datasource_sync_status",
CLIPath: "aitable datasource sync-status",
PrimaryCLIPath: "aitable datasource sync-status",
},
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
Interface: aitableMCPInterface("get_datasource_sync_status"),
Selection: contract.SelectionSpec{
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
AvoidWhen: []string{"触发同步用 datasource sync"},
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
},
},
})
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceCmd.AddCommand(
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
datasourceCreateCmd, datasourceUpdateCmd,
datasourceSyncCmd, datasourceSyncStatusCmd,
)
// 组装 aitable 命令树
root.AddCommand(
baseCmd, tableCmd, fieldCmd,
@@ -8432,6 +8844,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
attachmentCmd, templateCmd,
advpermCmd,
sectionCmd,
datasourceCmd,
)
// 批量注册 --base 作为 --base-id 的隐藏别名
+462
View File
@@ -0,0 +1,462 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableDatasourceCaller struct {
calls []aitableTestCall
}
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
}}}, nil
}
func (*aitableDatasourceCaller) Format() string { return "json" }
func (*aitableDatasourceCaller) DryRun() bool { return false }
func (*aitableDatasourceCaller) Fields() string { return "" }
func (*aitableDatasourceCaller) JQ() string { return "" }
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableDatasourceCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
root := newAitableCommand()
root.SetArgs(append([]string{"datasource"}, args...))
return caller, root.Execute()
}
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids required", err)
}
}
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids error", err)
}
}
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5")
if err != nil {
t.Fatalf("5 table-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1")
if err != nil {
t.Fatalf("single table-id should succeed: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
}
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
if err != nil {
t.Fatalf("5 task-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "task-ids") {
t.Fatalf("error = %v, want task-ids required", err)
}
}
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-config",
"--base-id", "BASE123", "--table-id", "TBL456")
if err != nil {
t.Fatalf("get-config should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["tableId"] != "TBL456" {
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
}
}
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "list-sources",
"--base-id", "BASE123", "--datasource-type", "OA")
if err != nil {
t.Fatalf("list-sources should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
t.Fatalf("error = %v, want datasource-type required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("get-fields should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
}
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceCreateSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("create should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false when not provided", v)
}
}
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto")
if err != nil {
t.Fatalf("create with --auto should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
t.Fatalf("error = %v, want at least one config change required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when no changes provided")
}
}
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
if err != nil {
t.Fatalf("update with --auto only should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("update with source-config should succeed: %v", err)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
if _, ok := caller.calls[0].args["auto"]; ok {
t.Fatalf("auto should not be sent when --auto is omitted")
}
}
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
if err != nil {
t.Fatalf("update with --auto=false should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false", v)
}
}
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", raw)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected")
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
}
}
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
+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(),
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
cmd := newCollegeContactCommand()
if cmd.Name() != "college-contact" {
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
}
if !cmd.Hidden {
t.Error("extension root command should be Hidden")
}
// 分组 → 叶子命令映射
groups := map[string][]string{
"dept": {
"get-standard-structure", "get-detail", "get-chain", "search",
"create", "update", "delete", "batch-update-type", "overview",
},
"employee": {
"get-detail", "add", "remove", "change-type", "change-dept",
"send-active-sms", "list-employees", "list-unaccepted",
"list-unactive", "upgrade-status", "start-upgrade",
},
"alumni": {
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
},
"graduate": {
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
},
"group": {
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
},
}
for groupName, leaves := range groups {
var groupCmd *cobra.Command
for _, c := range cmd.Commands() {
if c.Name() == groupName {
groupCmd = c
break
}
}
if groupCmd == nil {
t.Fatalf("subcommand group %q not found", groupName)
}
for _, leaf := range leaves {
found := false
for _, c := range groupCmd.Commands() {
if c.Name() == leaf {
found = true
break
}
}
if !found {
t.Errorf("leaf command %q not found under %q", leaf, groupName)
}
}
}
// stats 分组已移除
for _, c := range cmd.Commands() {
if c.Name() == "stats" {
t.Error("subcommand group 'stats' should be removed")
}
}
}
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newCollegeContactCommand())
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
if err != nil {
t.Fatalf("command path not found: %v", err)
}
if c.Name() != "get-standard-structure" {
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
}
}
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newCollegeContactTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
return root
}
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
t.Helper()
root := newCollegeContactTestRoot()
root.SetArgs(args)
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
err = root.Execute()
return err, false
}
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if panicked {
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
}
if err == nil {
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
}
if !strings.Contains(err.Error(), "--yes") {
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
}
}
}
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if !panicked {
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
}
}
}
}
// withCollegeContactCaller installs a dry-run capture caller so happy-path
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
// without requiring a live MCP transport. In dry-run mode destructive
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactHappyPaths runs every leaf command with required flags only
// and with all optional flags populated, expecting a nil error (dry-run preview).
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure"},
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-detail", "--dept-id", "123"},
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-chain", "--dept-id", "123"},
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "delete", "--dept-id", "123"},
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
{"dept", "overview"},
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail", "--staff-id", "S1"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "remove", "--staff-ids", "S1"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "send-active-sms", "--dept-id", "100"},
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-employees", "--dept-id", "123"},
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unaccepted", "--dept-id", "123"},
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unactive", "--dept-id", "123"},
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "upgrade-status"},
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"employee", "start-upgrade"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
{"alumni", "get-info", "--alumni-dept-id", "123"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
{"alumni", "query", "--staff-id", "S1"},
{"alumni", "search", "--keyword", "x"},
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
{"alumni", "get-group", "--alumni-dept-id", "1"},
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
{"alumni", "create-group", "--alumni-dept-id", "1"},
{"alumni", "disband-group", "--alumni-dept-id", "1"},
{"alumni", "get-alumni-org-from-graduate"},
{"alumni", "create-alumni-org", "--org-name", "O"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-years"},
{"graduate", "query-graduate-depts", "--dept-id", "1"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
{"graduate", "get-task-result", "--request-no", "r1"},
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
{"graduate", "get-alumni-org"},
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
{"graduate", "search-graduate", "--keyword", "x"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "all-graduate", "--graduate-year", "2026"},
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule"},
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
{"group", "get-group-rule-schedule"},
{"group", "query-preview-data"},
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
{"group", "delete-group-rule", "--rule-id", "1"},
{"group", "enable-group-rule", "--rule-id", "1"},
{"group", "disable-group-rule", "--rule-id", "1"},
{"group", "set-group-rule-schedule"},
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
{"group", "execute-group-rule"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err != nil {
t.Errorf("%v: expected nil error, got: %v", args, err)
}
}
}
// TestCollegeContactValidationErrors exercises every validation-error branch:
// missing required flags, non-integer int flags, invalid bool flags, and
// empty-after-split CSV lists. Each case must return a non-nil error.
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure", "--dept-id", "abc"},
{"dept", "get-standard-structure", "--offset", "abc"},
{"dept", "get-standard-structure", "--size", "abc"},
{"dept", "get-detail"},
{"dept", "get-detail", "--dept-id", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
{"dept", "get-chain"},
{"dept", "get-chain", "--dept-id", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
{"dept", "search"},
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
{"dept", "search", "--dept-id", "1"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
{"dept", "create"},
{"dept", "create", "--super-id", "abc"},
{"dept", "create", "--super-id", "100"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
{"dept", "update"},
{"dept", "update", "--dept-id", "abc"},
{"dept", "update", "--dept-id", "1"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "delete"},
{"dept", "delete", "--dept-id", "abc"},
{"dept", "batch-update-type"},
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", "1,2"},
{"dept", "overview", "--dept-id", "abc"},
{"dept", "overview", "--offset", "abc"},
{"dept", "overview", "--size", "abc"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
{"employee", "add"},
{"employee", "add", "--emp-type", "x"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
{"employee", "remove"},
{"employee", "remove", "--staff-ids", ",,"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
{"employee", "change-type"},
{"employee", "change-type", "--staff-id", "S1"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
{"employee", "change-dept"},
{"employee", "change-dept", "--staff-id", "S1"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
{"employee", "send-active-sms"},
{"employee", "send-active-sms", "--dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-employees"},
{"employee", "list-employees", "--dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unaccepted"},
{"employee", "list-unaccepted", "--dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unactive"},
{"employee", "list-unactive", "--dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "upgrade-status", "--dept-id", "abc"},
{"employee", "upgrade-status", "--offset", "abc"},
{"employee", "upgrade-status", "--size", "abc"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree"},
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
{"alumni", "get-info"},
{"alumni", "get-info", "--alumni-dept-id", "abc"},
{"alumni", "list"},
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
{"alumni", "query"},
{"alumni", "search"},
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
{"alumni", "list-unaccepted"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
{"alumni", "get-group"},
{"alumni", "get-group", "--alumni-dept-id", "abc"},
{"alumni", "create-dept"},
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "create-dept", "--alumni-dept-id", "1"},
{"alumni", "update-dept"},
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1"},
{"alumni", "delete-dept"},
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
{"alumni", "update-managers"},
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
{"alumni", "update-managers", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
{"alumni", "add-alumnus"},
{"alumni", "add-alumnus", "--name", "X"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
{"alumni", "update-alumnus"},
{"alumni", "update-alumnus", "--staff-id", "S1"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
{"alumni", "remove-alumnus"},
{"alumni", "remove-alumnus", "--staff-id", "S1"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
{"alumni", "cancel-invite"},
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
{"alumni", "create-group"},
{"alumni", "create-group", "--alumni-dept-id", "abc"},
{"alumni", "disband-group"},
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
{"alumni", "create-alumni-org"},
{"alumni", "add-alumni-org-main-admins"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-depts"},
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
{"graduate", "query-graduate-sub-depts"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users"},
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
{"graduate", "get-task-result"},
{"graduate", "query-restore-sub-depts"},
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
{"graduate", "search-graduate"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
{"graduate", "commit-graduate"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
{"graduate", "all-graduate"},
{"graduate", "all-graduate", "--graduate-year", "abc"},
{"graduate", "batch-graduate"},
{"graduate", "batch-graduate", "--dept-id", "abc"},
{"graduate", "batch-graduate", "--dept-id", "1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "delete-and-graduate"},
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
{"graduate", "delete-and-graduate", "--dept-id", "1"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-delete-pending"},
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
{"graduate", "batch-delete-pending", "--dept-id", "1"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending"},
{"graduate", "batch-update-pending", "--dept-id", "abc"},
{"graduate", "batch-update-pending", "--dept-id", "1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
{"graduate", "commit-restore"},
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule", "--offset", "abc"},
{"group", "query-group-rule", "--size", "abc"},
{"group", "query-preview-data", "--offset", "abc"},
{"group", "query-preview-data", "--size", "abc"},
{"group", "create-group-rule"},
{"group", "create-group-rule", "--name", "X"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
{"group", "delete-group-rule"},
{"group", "delete-group-rule", "--rule-id", "abc"},
{"group", "enable-group-rule"},
{"group", "enable-group-rule", "--rule-id", "abc"},
{"group", "disable-group-rule"},
{"group", "disable-group-rule", "--rule-id", "abc"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err == nil {
t.Errorf("%v: expected non-nil error, got nil", args)
}
}
}
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
// user_required destructive leaf in a paired manner:
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
// productID, tool name, and complete argument payload.
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
type destructiveCase struct {
name string
args []string
wantTool string
wantInput map[string]any
}
cases := []destructiveCase{
{
"dept delete",
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
"delete_college_contact_dept",
map[string]any{"deptId": int64(123)},
},
{
"employee remove",
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
"remove_employee",
map[string]any{"staffIds": []string{"S1", "S2"}},
},
{
"alumni delete-dept",
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
"delete_alumni_dept",
map[string]any{"alumniDeptId": int64(1)},
},
{
"alumni remove-alumnus",
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
"delete_alumnus",
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
},
{
"alumni cancel-invite",
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
"delete_alumni_invite_record",
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"alumni disband-group",
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
"disband_alumni_group",
map[string]any{"alumniDeptId": int64(1)},
},
{
"graduate commit-graduate",
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
"commit_graduate",
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
},
{
"graduate all-graduate",
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
"all_graduate",
map[string]any{"graduateYear": int64(2026)},
},
{
"graduate batch-graduate",
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate delete-and-graduate",
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"delete_and_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-delete-pending",
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_delete_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-update-pending",
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
"batch_update_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
},
{
"graduate commit-restore",
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
"commit_restore",
map[string]any{"graduateDeptIds": []int64{1, 2}},
},
{
"group delete-group-rule",
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
"delete_group_rule",
map[string]any{"ruleId": int64(1)},
},
{
"group execute-group-rule",
[]string{"college-contact", "group", "execute-group-rule"},
"execute_group_rule",
map[string]any{},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
argsWithYes := append(append([]string{}, tc.args...), "--yes")
root.SetArgs(argsWithYes)
err := root.Execute()
if err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "college-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
// verify the productID, tool name, and args passed to callMCPToolOnServer.
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactDispatch verifies that representative commands from each
// group dispatch to the correct MCP tool with the expected productID and args.
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
type dispatchCase struct {
name string
args []string
wantTool string
wantProd string
checkArgs func(t *testing.T, args map[string]any)
}
cases := []dispatchCase{
{
name: "dept get-standard-structure",
args: []string{"college-contact", "dept", "get-standard-structure"},
wantTool: "get_college_standard_structure",
wantProd: "college-contact",
},
{
name: "dept get-detail",
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
wantTool: "get_college_dept_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
{
name: "dept create",
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
wantTool: "create_college_contact_dept",
wantProd: "college-contact",
},
{
name: "employee get-detail",
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
wantTool: "get_employee_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["staffId"] != "S1" {
t.Errorf("staffId = %v, want S1", input["staffId"])
}
},
},
{
name: "alumni get-dept-tree",
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
wantTool: "get_alumni_dept_tree",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["alumniDeptId"] != int64(123) {
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
}
},
},
{
name: "graduate query-graduate-years",
args: []string{"college-contact", "graduate", "query-graduate-years"},
wantTool: "query_graduate_years",
wantProd: "college-contact",
},
{
name: "group query-group-rule",
args: []string{"college-contact", "group", "query-group-rule"},
wantTool: "query_group_rule",
wantProd: "college-contact",
},
{
name: "dept delete with --yes",
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
wantTool: "delete_college_contact_dept",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := withCollegeContactDispatchCaller(t)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if caller.productID != tc.wantProd {
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
}
if caller.tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
}
if tc.checkArgs != nil {
tc.checkArgs(t, caller.args)
}
})
}
}
+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",
@@ -10,6 +10,8 @@ import (
"runtime"
"sync"
"testing"
"github.com/spf13/cobra"
)
const (
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
if os.Getenv(helpersShellStubEnv) == "1" {
os.Exit(runHelpersShellStub())
}
// Cobra's Windows pre-exec hook walks the process table on every Execute*
// call to detect Explorer launches. Helpers tests execute command trees
// thousands of times, and none of those in-process invocations can be an
// Explorer launch, so keep that production-only check out of the test
// process. This also prevents every new exhaustive harness from having to
// remember a test-local override.
originalMousetrapHelpText := cobra.MousetrapHelpText
cobra.MousetrapHelpText = ""
code := m.Run()
cobra.MousetrapHelpText = originalMousetrapHelpText
if helpersShellStubBaseDir != "" {
_ = os.RemoveAll(helpersShellStubBaseDir)
}
+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()
}
@@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
})
}
}
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
root := build()
os.Args = append([]string{"dws", root.Name()}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
// destructive confirmation gate on the three batch remove entry points
// (drive/doc permission remove, wiki member remove). Removing up to 30
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
// departments, chats, or role groups, so every invocation — new --members
// format and legacy --users alike — must confirm first: without --yes the
// command fails with the typed confirmation_required error and performs zero
// MCP calls; with --yes it dispatches exactly one call with the complete,
// precise tool arguments; --dry-run previews without any call.
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
cases := []struct {
name string
build func() *cobra.Command
args []string
product string
tool string
wantArgs map[string]any
}{
{
name: "drive permission remove --members",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{
{"type": "CONVERSATION", "id": "cid1"},
{"type": "TAG", "id": "t1", "corpId": "c1"},
},
},
},
{
name: "drive permission remove --users",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"userIds": []string{"uid1", "uid2"},
},
},
{
name: "doc permission remove --members",
build: newDocCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --members",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --users",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"userIds": []string{"uid1"},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
caller := &guardedMutationCaller{}
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
}
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
confirmed := &guardedMutationCaller{}
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
t.Fatalf("confirmed remove returned error: %v", err)
}
if len(confirmed.calls) != 1 {
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
}
call := confirmed.calls[0]
if call.productID != tc.product || call.toolName != tc.tool {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
}
if !reflect.DeepEqual(call.args, tc.wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
}
// 3) --dry-run 预览:不触发任何 MCP 调用。
dryRun := &guardedMutationCaller{dryRun: true}
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
t.Fatalf("dry-run remove returned error: %v", err)
}
if len(dryRun.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
}
})
}
}
+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",
+242 -64
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,22 +3545,31 @@ 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",
Short: "添加文档协作者",
Args: cobra.NoArgs,
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
支持的角色 (--role)(必须大写):
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色(大小写不敏感):
MANAGER 管理员,可读写、管理成员
EDITOR 编辑者,可查看、编辑、上传内容
DOWNLOADER 查看下载者,可查看并下载内容
@@ -3568,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
注意:
- OWNER 角色不可通过此接口添加。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 单次请求最多 30 个成员,超出请分批调用。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3627,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3636,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionAddCmd.Flags().String("user", "", "")
_ = permissionAddCmd.Flags().MarkHidden("user")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
permissionUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新文档协作者权限",
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
Args: cobra.NoArgs,
Long: `更新指定节点已有协作者的权限角色。
支持的角色 (--role)(必须大写):
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色 (--role)(大小写不敏感):
MANAGER 管理员
EDITOR 编辑者
DOWNLOADER 查看下载者
@@ -3657,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
- OWNER 角色不可通过此接口变更。
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3710,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3719,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionUpdateCmd.Flags().String("user", "", "")
_ = permissionUpdateCmd.Flags().MarkHidden("user")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
permissionListCmd := &cobra.Command{
Use: "list",
@@ -3731,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Short: "查询文档协作者列表",
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
Example: ` dws doc permission list --node DOC_ID
dws doc permission list --node DOC_ID --limit 100
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
dws doc permission list --node DOC_ID --limit 50
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -3744,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
toolArgs := map[string]any{
"nodeId": nodeID,
}
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
}
if limit > 0 {
toolArgs["maxResults"] = limit
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -3789,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
{Name: "limit", Property: "maxResults"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
permissionListCmd.Flags().Int("max-results", 0, "")
_ = permissionListCmd.Flags().MarkHidden("max-results")
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除文档协作者权限",
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
Long: `从指定节点移除一个或多个协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
注意:
- OWNER 角色不可通过此接口移除。
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 单次请求最多 30 个成员,超出请分批调用。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission remove --node DOC_ID --users uid1
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3842,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
}
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -3866,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3873,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
})
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionRemoveCmd.Flags().String("user", "", "")
_ = permissionRemoveCmd.Flags().MarkHidden("user")
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
// permission 子命令的 --node 隐藏别名
@@ -4166,6 +4260,7 @@ CLI 内部自动完成全部流程:
_ = exportCmd.Flags().MarkHidden("file-id")
exportCmd.AddCommand(exportGetCmd)
newHybridGroupCommand(exportCmd)
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
importCmd := &cobra.Command{
@@ -4262,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",
@@ -4455,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",
@@ -5199,3 +5295,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
}
return userIds, nil
}
// collectMembers parses the --members JSON array flag (new format), returning
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
// member types, each carrying an independent roleId.
//
// When onlyTypeID is true (remove operations), roleId is not required —
// only type and id are needed.
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
raw := mustGetFlag(cmd, "members")
if raw == "" {
return nil, nil
}
var members []map[string]any
if err := json.Unmarshal([]byte(raw), &members); err != nil {
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
}
if len(members) == 0 {
return nil, fmt.Errorf("--members 不能为空数组")
}
if len(members) > 30 {
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
}
for i, m := range members {
mt, ok := m["type"].(string)
if !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
}
if _, ok := m["id"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
}
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
}
if !onlyTypeID {
if _, ok := m["roleId"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
}
if r, ok := m["roleId"].(string); ok {
m["roleId"] = normalizePermissionRole(r)
}
}
}
return members, nil
}
// validateMembersExclusivity ensures --members (new format) and --users (legacy
// format) are not used simultaneously. Exactly one must be provided.
func validateMembersExclusivity(cmd *cobra.Command) error {
hasMembers := mustGetFlag(cmd, "members") != ""
hasUsers := flagOrFallback(cmd, "users", "user") != ""
if hasMembers && hasUsers {
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
}
if !hasMembers && !hasUsers {
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
}
if hasMembers && mustGetFlag(cmd, "role") != "" {
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
}
return nil
}
// permissionPageSizeFromFlags resolves and validates the page size for the
// permission / member list commands. Both --limit and the hidden
// --max-results alias map to the server pageSize, whose accepted range is
// 1..50 (the backend rejects pageSize > 50 with
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
// It returns (size, true, nil) when a page size was explicitly provided.
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
for _, name := range []string{"limit", "max-results"} {
if !cmd.Flags().Changed(name) {
continue
}
size, _ := cmd.Flags().GetInt(name)
if size < 1 || size > 50 {
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
}
return size, true, nil
}
return 0, false, nil
}
+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",
+279 -61
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,39 +2030,67 @@ 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",
Short: "添加协作者",
Args: cobra.NoArgs,
Long: `为文档空间节点添加协作成员并授予指定角色。
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermAddCmd.Flags().String("user", "", "")
_ = drivePermAddCmd.Flags().MarkHidden("user")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
drivePermUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新协作者权限",
Args: cobra.NoArgs,
Long: `更新文档空间节点已有协作者的权限角色。
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermUpdateCmd.Flags().String("user", "", "")
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
drivePermListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询协作者列表",
Long: `查询文档空间节点的协作者列表。`,
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount、hasMore 和 nextToken。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
Example: ` dws drive permission list --node DOC_ID
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
}
if limit > 0 {
toolArgs["maxResults"] = limit
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -2251,38 +2318,184 @@ func newDriveCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
{Name: "limit", Property: "maxResults"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除协作者权限",
Long: `从文档空间节点移除协作成员的权限。`,
Example: ` dws drive permission remove --node DOC_ID --users uid1
dws drive permission remove --node DOC_ID --users uid1,uid2`,
drivePermGetSettingCmd := &cobra.Command{
Use: "get-setting",
Short: "查询节点权限设置",
Long: `查询文档空间节点的权限设置,返回三部分配置:
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
- shareScope: 分享范围(可见范围、链接分享设置)
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
查询协作者列表请改用 permission list。`,
Example: ` dws drive permission get-setting --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_permission_setting",
CanonicalPath: "drive.get_permission_setting",
CLIPath: "drive permission get-setting",
PrimaryCLIPath: "drive permission get-setting",
},
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"节点权限设置(权限模式/分享范围/权限策略)",
"properties":{
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
"shareScope":{
"type":"object",
"description":"分享范围设置",
"properties":{
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"linkShare":{
"type":"object",
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
"properties":{
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
},
"additionalProperties":true
}
},
"additionalProperties":true
},
"policies":{
"type":"array",
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
"items":{
"type":"object",
"description":"权限策略项",
"properties":{
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
"disabledValues":{
"type":"array",
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
"items":{
"type":"object",
"properties":{
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
},
"required":["value"],
"additionalProperties":true
}
},
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
},
"required":["code","name","description","disabledValues"],
"additionalProperties":true
}
}
},
"required":["docUrl","nodeId","shareScope","policies"],
"additionalProperties":true
}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
AvoidWhen: []string{
"查协作者清单用 permission list",
"查可申请角色与审批人用 permission apply-info",
},
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除协作者权限",
Long: `从文档空间节点移除协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
Example: ` dws drive permission remove --node DOC_ID --users uid1
dws drive permission remove --node DOC_ID --users uid1,uid2
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2292,8 +2505,11 @@ func newDriveCommand() *cobra.Command {
}
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2319,6 +2535,7 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2326,13 +2543,14 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermRemoveCmd.Flags().String("user", "", "")
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} {
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
@@ -2564,7 +2782,7 @@ func newDriveCommand() *cobra.Command {
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
@@ -2588,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",
@@ -2703,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)",
@@ -2733,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",
@@ -3033,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: "收藏文档",
@@ -0,0 +1,237 @@
// 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.
package helpers
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
)
// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--node", "node-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("call = %#v", call)
}
if len(call.args) != 1 || call.args["nodeId"] != "node-1" {
t.Fatalf("args = %#v, want only nodeId=node-1", call.args)
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) {
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--"+alias, "node-alias")
if err != nil {
t.Fatalf("alias --%s: %v", alias, err)
}
if len(caller.calls) != 1 {
t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("alias --%s call = %#v", alias, call)
}
if call.args["nodeId"] != "node-alias" {
t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args)
}
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting")
if err == nil || !strings.Contains(err.Error(), "flag --node is required") {
t.Fatalf("err = %v, want flag --node is required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls)
}
}
// ── drive permission get-setting:ResultSpec 返回值契约 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) {
drive := newDriveCommand()
leaf, _, err := drive.Find([]string{"permission", "get-setting"})
if err != nil || leaf == nil {
t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err)
}
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" {
t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok)
}
if final.Result == nil {
t.Fatal("get-setting final Result is nil")
}
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
}
var root map[string]any
if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil {
t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema)
}
assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies")
properties := resultSchemaProperties(t, final.Result.DataSchema)
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) {
t.Fatalf("result properties = %#v", got)
}
permissionMode, ok := properties["permissionMode"].(map[string]any)
if !ok {
t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"])
}
if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", "<null>"}) {
t.Fatalf("permissionMode enum = %#v", got)
}
if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"])
}
shareScope := schemaObjectProperty(t, properties, "shareScope")
shareScopeProperties := schemaProperties(t, shareScope)
if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) {
t.Fatalf("shareScope properties = %#v", got)
}
if _, ok := shareScope["required"]; ok {
t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"])
}
if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", "<null>"}) {
t.Fatalf("visibility enum = %#v", got)
}
if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", "<null>"}) {
t.Fatalf("defaultRole enum = %#v", got)
}
linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare"))
if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) {
t.Fatalf("linkShare properties = %#v", got)
}
policies, ok := properties["policies"].(map[string]any)
if !ok || policies["type"] != "array" {
t.Fatalf("policies = %#v, want array schema", properties["policies"])
}
policiesDescription, _ := policies["description"].(string)
for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} {
if !strings.Contains(policiesDescription, fragment) {
t.Fatalf("policies description missing %q: %s", fragment, policiesDescription)
}
}
items, ok := policies["items"].(map[string]any)
if !ok {
t.Fatalf("policies items = %#v", policies["items"])
}
assertSchemaRequired(t, items, "code", "name", "description", "disabledValues")
itemProperties := schemaProperties(t, items)
if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) {
t.Fatalf("policy item properties = %#v", got)
}
code, ok := itemProperties["code"].(map[string]any)
if !ok {
t.Fatalf("code = %#v", itemProperties["code"])
}
if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{
"external_share", "external_share_manager_only", "member_invite", "member_invite_org_only",
"comment", "permission_apply", "external_permission_apply", "watermark", "node_spread",
"online_content_copy", "node_move_forbidden", "node_spread_scope",
}) {
t.Fatalf("code enum = %#v", got)
}
value, ok := itemProperties["value"].(map[string]any)
if !ok {
t.Fatalf("value = %#v", itemProperties["value"])
}
valueDescription, _ := value["description"].(string)
for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} {
if !strings.Contains(valueDescription, fragment) {
t.Fatalf("value description missing %q: %s", fragment, valueDescription)
}
}
for _, field := range []string{"name", "description"} {
entry, ok := itemProperties[field].(map[string]any)
if !ok || entry["type"] != "string" {
t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field])
}
entryDescription, _ := entry["description"].(string)
if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") {
t.Fatalf("policy item %s description = %q", field, entryDescription)
}
}
disabledValues, ok := itemProperties["disabledValues"].(map[string]any)
if !ok || disabledValues["type"] != "array" {
t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"])
}
disabledValuesDescription, _ := disabledValues["description"].(string)
for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} {
if !strings.Contains(disabledValuesDescription, fragment) {
t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription)
}
}
disabledItems, ok := disabledValues["items"].(map[string]any)
if !ok {
t.Fatalf("disabledValues items = %#v", disabledValues["items"])
}
assertSchemaRequired(t, disabledItems, "value")
disabledItemProperties := schemaProperties(t, disabledItems)
if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) {
t.Fatalf("disabledValues item properties = %#v", got)
}
if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" {
t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"])
}
reason, ok := disabledItemProperties["reason"].(map[string]any)
if !ok {
t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"])
}
if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"])
}
allowedValues, ok := itemProperties["allowedValues"].(map[string]any)
if !ok {
t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"])
}
if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") {
t.Fatalf("allowedValues description = %q", allowedValues["description"])
}
}
func schemaEnumValues(t *testing.T, property map[string]any) []string {
t.Helper()
raw, ok := property["enum"].([]any)
if !ok {
t.Fatalf("schema enum = %#v, want array", property["enum"])
}
values := make([]string, 0, len(raw))
for _, value := range raw {
switch typed := value.(type) {
case string:
values = append(values, typed)
case nil:
values = append(values, "<null>")
default:
t.Fatalf("schema enum value = %#v, want string or null", value)
}
}
return values
}
File diff suppressed because it is too large Load Diff
+503
View File
@@ -0,0 +1,503 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func runEduApp(t *testing.T, args ...string) error {
t.Helper()
cmd := newEduAppCommand()
cmd.SetArgs(args)
return cmd.Execute()
}
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
// populated, so all optional-field branches and the dispatch line are covered.
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
cases := [][]string{
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
{"report", "get", "--ids", "1001,1002"},
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
{"card", "end", "--card-id", "1"},
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
cases := [][]string{
{"report", "by-teacher", "--page", "0", "--limit", "0"},
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
{"student-list missing students", []string{"task", "student-list"}, "students"},
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
{"report get missing ids", []string{"report", "get"}, "ids"},
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
{"card end missing card-id", []string{"card", "end"}, "card-id"},
{"card list missing status", []string{"card", "list"}, "status"},
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduAppCaller(t)
err := runEduApp(t, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduAppParseCSV = %#v", got)
}
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduAppParseIntCSV invalid = nil error")
}
}
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_ai_message_summary_list" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != int64(100) {
t.Fatalf("classId = %v, want 100", input["classId"])
}
if input["cid"] != "cidxxx" {
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
if input["status"] != int64(1) {
t.Fatalf("status = %v, want 1", input["status"])
}
})
t.Run("report get dispatches get_report", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_report" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
ids, ok := input["schoolReportIdList"].([]int64)
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
}
})
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "query_student_circle_posts" {
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != "12345" {
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
}
if input["studentId"] != "stu1" {
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
})
t.Run("card end dispatches end_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "end_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(999) {
t.Fatalf("cardId = %v, want 999", input["cardId"])
}
})
t.Run("card update dispatches update_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "update_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(77) {
t.Fatalf("cardId = %v, want 77", input["cardId"])
}
if input["identifier"] != "org1-staff1-uuid" {
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
}
if input["title"] != "新标题" {
t.Fatalf("title = %v, want %q", input["title"], "新标题")
}
if input["shouldSendUpdateMsg"] != true {
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
}
})
}
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduAppConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduAppCommand())
return root
}
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"notice delete",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
"delete_notice",
map[string]any{"noticeId": int64(12345)},
},
{
"notice delete with user-name",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
"delete_notice",
map[string]any{"noticeId": int64(12345), "userName": "张三"},
},
{
"homework delete",
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
"delete_homework",
map[string]any{"homeworkId": int64(12345)},
},
{
"diploma delete",
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
"delete_diploma",
map[string]any{"diplomaId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-app" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newTestEduContactRoot() *cobra.Command {
return newEduContactCommand()
}
// ──────────────────────────────────────────────────────────
// 命令注册测试 — 验证所有子命令路径是否正确注册
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
root := newTestEduContactRoot()
paths := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "class-list"},
// class — 原有
{"class", "detail"},
{"class", "students"},
{"class", "teachers"},
{"class", "same-name"},
{"class", "user-role"},
{"class", "search-by-name"},
{"class", "headmaster"},
// class — 新增
{"class", "search-by-teacher"},
{"class", "add-student"},
{"class", "add-teachers"},
{"class", "add-unofficial-student"},
{"class", "delete-students"},
{"class", "delete-teacher"},
{"class", "modify-student-info"},
{"class", "move-student"},
{"class", "update-info"},
{"class", "update-student"},
{"class", "update-student-mobile"},
{"class", "update-student-number"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course"},
}
for _, path := range paths {
if _, _, err := root.Find(path); err != nil {
t.Errorf("command path %v not found: %v", path, err)
}
}
}
// ──────────────────────────────────────────────────────────
// 参数校验测试 — 验证必填参数缺失时返回错误
// ──────────────────────────────────────────────────────────
func executeCommand(root *cobra.Command, args ...string) error {
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "search-by-teacher")
if err == nil {
t.Fatal("expected error for missing --name, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
if err == nil {
t.Fatal("expected error for invalid --is-adviser, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --origin-class-id, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --mobile, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-staff-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --student-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
if err == nil {
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --student-number, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
if err == nil {
t.Fatal("expected error for --group-name without --conversation-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
if err == nil {
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
if err == nil {
t.Fatal("expected error for missing --student-name, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
if err == nil {
t.Fatal("expected error for missing mobile (student or parent), got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
if err == nil {
t.Fatal("expected error for invalid --mother JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course")
if err == nil {
t.Fatal("expected error for missing --teacher-class-infos, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
if err == nil {
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
if err == nil {
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
}
}
// ──────────────────────────────────────────────────────────
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
// ──────────────────────────────────────────────────────────
// withEduContactCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
withEduContactCaller(t)
cases := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "stats", "--statistics-type", "1"},
{"school", "class-list"},
// class — 读操作
{"class", "detail", "--dept-id", "123"},
{"class", "students", "--dept-id", "123"},
{"class", "teachers", "--dept-id", "123"},
{"class", "same-name", "--dept-id", "123"},
{"class", "user-role", "--dept-id", "123"},
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
{"class", "headmaster", "--class-name", "一年级1班"},
{"class", "search-by-teacher", "--name", "张老师"},
// class — update-student 各分支
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
// class — add-student 各手机号来源分支
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--student-mobile", "13800138000", "--student-user-id", "u1",
"--student-number", "S1", "--virtual-account-id", "v1"},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--mother", `{"mobile":"13800138000","relation":"M"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--father", `{"mobile":"13900139000","relation":"F"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
// class — modify-student-info 两个分支
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
"--patriarch-user-id", "p1", "--relation", "父亲"},
// class — delete-teacher
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
// class — update-info 三个可选分支
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
// class — update-student-number
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
// class — add-unofficial-student
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
// class — delete-students
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
// class — update-student-mobile
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
// class — move-student
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
// class — add-teachers 默认/班主任
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
}
for _, args := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, args...); err != nil {
t.Errorf("happy path %v returned error: %v", args, err)
}
}
}
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
// the existing error tests, ensuring 100% changed-code coverage.
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
cases := []struct {
name string
args []string
}{
// eduRequiredIntFlag:空值 + 非整数
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
{"detail-missing-dept", []string{"class", "detail"}},
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
{"students-missing-dept", []string{"class", "students"}},
{"teachers-missing-dept", []string{"class", "teachers"}},
{"samename-missing-dept", []string{"class", "same-name"}},
{"userrole-missing-dept", []string{"class", "user-role"}},
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
{"headmaster-missing-classname", []string{"class", "headmaster"}},
// update-student
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
// add-student
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
// modify-student-info
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
// delete-teacher
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
// update-info
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
// update-student-number
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
// add-unofficial-student
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
// delete-students
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
// update-student-mobile
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
// move-student
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
// add-teachers
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
}
for _, tc := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, tc.args...); err == nil {
t.Errorf("%s: expected error, got nil", tc.name)
}
}
}
// ──────────────────────────────────────────────────────────
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
// ──────────────────────────────────────────────────────────
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
// the dispatched productID, tool name, and args.
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "get_class_detail" {
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(123) {
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
})
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "query_class_by_guardian_name" {
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["queryType"] != "student" {
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
}
if input["name"] != "张三" {
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
}
})
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "statistics_school" {
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["statisticsType"] != int64(2) {
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
}
})
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "batch_add_class_teacher" {
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(789) {
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
}
teacherUserIds, ok := input["teacherUserIds"].([]string)
if !ok {
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
}
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
}
if input["isAdviser"] != int64(1) {
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
}
})
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "move_student" {
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
studentUserIds, ok := input["studentUserIds"].([]string)
if !ok {
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
}
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
}
if input["originClassId"] != int64(100) {
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
}
if input["targetClassId"] != int64(200) {
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
}
})
}
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduContactConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduContactCommand())
return root
}
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"class delete-teacher",
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
"delete_teacher",
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
},
{
"class delete-students",
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
"delete_students",
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+605
View File
@@ -0,0 +1,605 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-familygroup — 家庭群管理
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
// ──────────────────────────────────────────────────────────
func newEduFamilyGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-familygroup",
Selection: contract.ProductSelectionDecl{
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
UseWhen: []string{
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
},
AvoidWhen: []string{
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
},
},
})
root := newGroupCommand(&cobra.Command{
Use: "edu-familygroup",
Short: "家庭群",
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
Hidden: true,
RunE: groupRunE,
})
// ════════════════════════════════════════════════════════════
// group 子命令组 — 家庭群读操作
// ════════════════════════════════════════════════════════════
groupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE})
groupCheckExistsCmd := &cobra.Command{
Use: "check-exists",
Short: "检查家庭群是否存在",
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
"input": map[string]any{"uid": uid, "groupName": groupName},
})
},
}
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "check_family_group_exists",
CanonicalPath: "edu-familygroup.check_family_group_exists",
CLIPath: "edu-familygroup group check-exists",
PrimaryCLIPath: "edu-familygroup group check-exists",
},
Description: "检查家庭群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查家庭群是否存在",
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
Examples: []string{
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
},
},
})
groupListChildrenCmd := &cobra.Command{
Use: "list-children",
Short: "查询家长绑定的孩子列表",
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group list-children --uid 12345
dws edu-familygroup group list-children --uid 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
"input": map[string]any{"uid": uid},
})
},
}
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "listBoundChildren",
CanonicalPath: "edu-familygroup.listBoundChildren",
CLIPath: "edu-familygroup group list-children",
PrimaryCLIPath: "edu-familygroup group list-children",
},
Description: "查询家长绑定的孩子列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询家长绑定的孩子列表",
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
Examples: []string{
"dws edu-familygroup group list-children --uid 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// manage 子命令组 — 家庭群写操作
// ════════════════════════════════════════════════════════════
manageCmd := newGroupCommand(&cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE})
manageCreateCmd := &cobra.Command{
Use: "create",
Short: "创建家庭群",
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childrenRaw, _ := cmd.Flags().GetString("children")
if strings.TrimSpace(childrenRaw) == "" {
return fmt.Errorf("--children 为必填参数")
}
var children []any
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateChildren(children); err != nil {
return err
}
input := map[string]any{"uid": uid, "children": children}
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
var addGroup map[string]any
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
}
if addGroup == nil {
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
}
input["addGroup"] = addGroup
}
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
input["source"] = v
}
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "create_family_group",
CanonicalPath: "edu-familygroup.create_family_group",
CLIPath: "edu-familygroup manage create",
PrimaryCLIPath: "edu-familygroup manage create",
},
Description: "创建家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建家庭群并同步创建家庭组织",
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
Examples: []string{
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "children", Property: "input.children", Required: boolPtr(true)},
{Name: "add-group", Property: "input.addGroup"},
{Name: "source", Property: "input.source"},
},
},
})
manageInviteParentCmd := &cobra.Command{
Use: "invite-parent",
Short: "短信邀请家长加入家庭群",
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
})
},
}
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "invite_parent_to_familygroup",
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
CLIPath: "edu-familygroup manage invite-parent",
PrimaryCLIPath: "edu-familygroup manage invite-parent",
},
Description: "短信邀请家长加入家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
},
Selection: contract.SelectionSpec{
AgentSummary: "通过短信邀请家长加入家庭群",
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
Examples: []string{
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
},
},
})
manageAddChildCmd := &cobra.Command{
Use: "add-child",
Short: "为家庭群添加孩子",
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
mobile 与 students 至少传一个。
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
if err != nil {
return err
}
mobile, _ := cmd.Flags().GetString("mobile")
mobile = strings.TrimSpace(mobile)
studentsRaw, _ := cmd.Flags().GetString("students")
studentsRaw = strings.TrimSpace(studentsRaw)
if mobile == "" && studentsRaw == "" {
return fmt.Errorf("--mobile 与 --students 至少传一个")
}
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
if mobile != "" {
input["mobile"] = mobile
}
if studentsRaw != "" {
var students []any
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateStudents(students); err != nil {
return err
}
input["students"] = students
}
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "add_child_to_family_group",
CanonicalPath: "edu-familygroup.add_child_to_family_group",
CLIPath: "edu-familygroup manage add-child",
PrimaryCLIPath: "edu-familygroup manage add-child",
},
Description: "为家庭群添加孩子",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为已有家庭群添加孩子",
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
AvoidWhen: []string{"创建全新家庭群用 create"},
Examples: []string{
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "name", Property: "input.name", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile"},
{Name: "students", Property: "input.students"},
},
},
})
manageToggleAppCmd := &cobra.Command{
Use: "toggle-app",
Short: "开启或关闭学生应用权限",
Long: `为指定学生号开启或关闭应用权限。
支持的应用类型(--app-type):
- XIAOTIANDI:小天地(学生圈)
- LEARNING_VIDEO:学习视频
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
if err != nil {
return err
}
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
if err != nil {
return err
}
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
}
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
if err != nil {
return err
}
var open bool
switch strings.ToLower(openStr) {
case "true":
open = true
case "false":
open = false
default:
return fmt.Errorf("--open 须为 true 或 false")
}
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
"input": map[string]any{
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
"appType": appType, "open": open,
},
})
},
}
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "toggle_student_app",
CanonicalPath: "edu-familygroup.toggle_student_app",
CLIPath: "edu-familygroup manage toggle-app",
PrimaryCLIPath: "edu-familygroup manage toggle-app",
},
Description: "开启或关闭学生应用权限",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
},
Selection: contract.SelectionSpec{
AgentSummary: "开启或关闭学生应用权限",
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
Examples: []string{
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
{Name: "open", Property: "input.open", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// group(读操作)flags
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
// manage(写操作)flags
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
root.AddCommand(groupCmd, manageCmd)
return root
}
// eduFamilyGroupValidateChildren validates the --children payload: the array
// must be non-empty, each child must carry a non-empty name and a non-empty
// students array, and each student must carry corpId + staffId.
func eduFamilyGroupValidateChildren(children []any) error {
if len(children) == 0 {
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
}
for i, c := range children {
child, ok := c.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
}
name, _ := child["name"].(string)
if strings.TrimSpace(name) == "" {
return fmt.Errorf("--children[%d].name 为必填字段", i)
}
students, ok := child["students"].([]any)
if !ok || len(students) == 0 {
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
}
for j, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
}
corpID, _ := student["corpId"].(string)
if strings.TrimSpace(corpID) == "" {
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
}
staffID, _ := student["staffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
}
}
}
return nil
}
// eduFamilyGroupValidateStudents validates the --students payload: the array
// must be non-empty and each element must carry a numeric schoolOrgId and a
// non-empty studentStaffId.
func eduFamilyGroupValidateStudents(students []any) error {
if len(students) == 0 {
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
}
for i, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
}
switch student["schoolOrgId"].(type) {
case float64, int, int64, json.Number:
default:
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
}
staffID, _ := student["studentStaffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
}
}
return nil
}
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
// error if the flag is empty or not a valid integer.
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return "", fmt.Errorf("--%s 为必填参数", name)
}
return v, nil
}
+318
View File
@@ -0,0 +1,318 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
)
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
{"group", "list-children", "--uid", "1"},
{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "XIAOTIANDI", "--open", "true"},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "LEARNING_VIDEO", "--open", "false"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "{"}, "add-group"},
{"create add-group null", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "null"}, "null"},
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
valid := []any{map[string]any{
"name": "小明",
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
}}
if err := eduFamilyGroupValidateChildren(valid); err != nil {
t.Fatalf("valid children error = %v", err)
}
cases := []struct {
name string
children []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
if err := eduFamilyGroupValidateStudents(valid); err != nil {
t.Fatalf("valid students error = %v", err)
}
cases := []struct {
name string
students []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
t.Run("check-exists", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "check_family_group_exists" {
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(123) {
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
}
if input["groupName"] != "测试家庭" {
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
}
})
t.Run("list-children", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "listBoundChildren" {
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(456) {
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
}
})
t.Run("create", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "create_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(789) {
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
}
if input["children"] == nil {
t.Fatalf("input[\"children\"] is nil, want non-nil")
}
})
t.Run("invite-parent", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "invite_parent_to_familygroup" {
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["orgId"] != int64(1) {
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
}
if input["uid"] != int64(2) {
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
}
if input["mobile"] != "13800138000" {
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
}
})
t.Run("toggle-app", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "toggle_student_app" {
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["appType"] != "XIAOTIANDI" {
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
}
if input["open"] != true {
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
}
})
t.Run("add-child", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
"--name", "小明", "--mobile", "13900139000",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "add_child_to_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
}
want := map[string]any{
"input": map[string]any{
"orgId": int64(12345),
"uid": int64(67890),
"name": "小明",
"mobile": "13900139000",
"students": []any{
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
},
},
}
if !reflect.DeepEqual(caller.args, want) {
t.Fatalf("args = %#v, want %#v", caller.args, want)
}
})
}
+846
View File
@@ -0,0 +1,846 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-group — 家校群管理
// 共 14 个工具,按 student-group / class-group / batch 分组
// ──────────────────────────────────────────────────────────
func newEduGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-group",
Selection: contract.ProductSelectionDecl{
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
UseWhen: []string{
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
},
AvoidWhen: []string{
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
},
},
})
root := newGroupCommand(&cobra.Command{
Use: "edu-group",
Short: "家校群",
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
Hidden: true,
RunE: groupRunE,
})
// ════════════════════════════════════════════════════════════
// student-group 子命令组 — 师生群管理
// ════════════════════════════════════════════════════════════
studentGroupCmd := newGroupCommand(&cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE})
studentGroupInfoCmd := &cobra.Command{
Use: "info",
Short: "查询班级师生群信息",
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group info --dept-id 12345
dws edu-group student-group info --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_group_info",
CanonicalPath: "edu-group.get_class_group_info",
CLIPath: "edu-group student-group info",
PrimaryCLIPath: "edu-group student-group info",
},
Description: "查询班级师生群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群信息",
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
Examples: []string{
"dws edu-group student-group info --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查组织是否已创建师生群",
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
Example: ` dws edu-group student-group exists --dept-id 12345
dws edu-group student-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_group_exists",
CanonicalPath: "edu-group.check_class_group_exists",
CLIPath: "edu-group student-group exists",
PrimaryCLIPath: "edu-group student-group exists",
},
Description: "检查组织是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查组织是否已创建师生群",
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
AvoidWhen: []string{"查询师生群成员用 student-group members"},
Examples: []string{
"dws edu-group student-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupMembersCmd := &cobra.Command{
Use: "members",
Short: "查询师生群成员列表",
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group members --dept-id 12345
dws edu-group student-group members --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_members",
CanonicalPath: "edu-group.get_group_members",
CLIPath: "edu-group student-group members",
PrimaryCLIPath: "edu-group student-group members",
},
Description: "查询师生群成员列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询师生群成员列表",
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
Examples: []string{
"dws edu-group student-group members --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupIsInCmd := &cobra.Command{
Use: "is-in",
Short: "判断用户是否在师生群中",
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group is-in --dept-id 12345
dws edu-group student-group is-in --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "is_in_class_group",
CanonicalPath: "edu-group.is_in_class_group",
CLIPath: "edu-group student-group is-in",
PrimaryCLIPath: "edu-group student-group is-in",
},
Description: "判断用户是否在师生群中",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "判断用户是否在师生群中",
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
AvoidWhen: []string{"查询群成员列表用 student-group members"},
Examples: []string{
"dws edu-group student-group is-in --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "查询班级群会话详情",
Long: `查询指定班级师生群的会话详情。
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group conversation --dept-id 12345
dws edu-group student-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_conversation_info",
CanonicalPath: "edu-group.get_group_conversation_info",
CLIPath: "edu-group student-group conversation",
PrimaryCLIPath: "edu-group student-group conversation",
},
Description: "查询班级群会话详情",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群会话详情",
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
Examples: []string{
"dws edu-group student-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupCreateCmd := &cobra.Command{
Use: "create",
Short: "创建班级师生群",
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group create --dept-id 12345
dws edu-group student-group create --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "create_class_group",
CanonicalPath: "edu-group.create_class_group",
CLIPath: "edu-group student-group create",
PrimaryCLIPath: "edu-group student-group create",
},
Description: "创建班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定班级创建师生群",
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
Examples: []string{
"dws edu-group student-group create --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupDisbandCmd := &cobra.Command{
Use: "disband",
Short: "解散班级师生群",
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group disband --dept-id 12345
dws edu-group student-group disband --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "disband_class_group",
CanonicalPath: "edu-group.disband_class_group",
CLIPath: "edu-group student-group disband",
PrimaryCLIPath: "edu-group student-group disband",
},
Description: "解散班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "解散班级师生群并删除关联关系",
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
AvoidWhen: []string{"查询师生群信息用 student-group info"},
Examples: []string{
"dws edu-group student-group disband --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// class-group 子命令组 — 班级群(家校群)会话管理
// ════════════════════════════════════════════════════════════
classGroupCmd := newGroupCommand(&cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE})
classGroupConversationIDCmd := &cobra.Command{
Use: "conversation-id",
Short: "获取班级群会话ID",
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation-id --dept-id 12345
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation_id",
CanonicalPath: "edu-group.get_class_conversation_id",
CLIPath: "edu-group class-group conversation-id",
PrimaryCLIPath: "edu-group class-group conversation-id",
},
Description: "获取班级群会话ID",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群会话ID",
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "获取班级群完整会话信息",
Long: `获取指定班级的班级群完整会话信息。
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation --dept-id 12345
dws edu-group class-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation",
CanonicalPath: "edu-group.get_class_conversation",
CLIPath: "edu-group class-group conversation",
PrimaryCLIPath: "edu-group class-group conversation",
},
Description: "获取班级群完整会话信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群完整会话信息",
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
Examples: []string{
"dws edu-group class-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查班级群是否存在",
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group exists --dept-id 12345
dws edu-group class-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_conversation_exists",
CanonicalPath: "edu-group.check_class_conversation_exists",
CLIPath: "edu-group class-group exists",
PrimaryCLIPath: "edu-group class-group exists",
},
Description: "检查班级群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查班级群是否存在",
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupListByConversationIDsCmd := &cobra.Command{
Use: "list-by-cids",
Short: "根据会话ID列表批量查询群信息",
Long: `根据群会话ID列表批量查询群的详细信息。
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("conversation-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--conversation-ids 为必填参数")
}
conversationIDs := eduGroupParseCSV(raw)
if len(conversationIDs) == 0 {
return fmt.Errorf("--conversation-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
"input": map[string]any{"conversationIds": conversationIDs},
})
},
}
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "list_groups_by_conversation_ids",
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
CLIPath: "edu-group class-group list-by-cids",
PrimaryCLIPath: "edu-group class-group list-by-cids",
},
Description: "根据会话ID列表批量查询群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
},
Selection: contract.SelectionSpec{
AgentSummary: "根据会话ID列表批量查询群信息",
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
Examples: []string{
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// batch 子命令组 — 批量操作
// ════════════════════════════════════════════════════════════
batchCmd := newGroupCommand(&cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE})
batchCheckClassGroupCmd := &cobra.Command{
Use: "check-student-group",
Short: "批量检查班级是否已创建师生群",
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs, err := eduGroupParseIntCSV(raw)
if err != nil {
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
}
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_check_class_group",
CanonicalPath: "edu-group.batch_check_class_group",
CLIPath: "edu-group batch check-student-group",
PrimaryCLIPath: "edu-group batch check-student-group",
},
Description: "批量检查班级是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量检查班级是否已创建师生群",
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
AvoidWhen: []string{"单个班级检查用 student-group exists"},
Examples: []string{
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchGetClassConversationsCmd := &cobra.Command{
Use: "get-class-groups",
Short: "批量获取班级群信息",
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs := eduGroupParseCSV(raw)
if len(classIDs) == 0 {
return fmt.Errorf("--class-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_get_class_conversations",
CanonicalPath: "edu-group.batch_get_class_conversations",
CLIPath: "edu-group batch get-class-groups",
PrimaryCLIPath: "edu-group batch get-class-groups",
},
Description: "批量获取班级群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量获取班级群会话信息",
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
Examples: []string{
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchCreateClassGroupCmd := &cobra.Command{
Use: "create-student-groups",
Short: "批量创建师生群",
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
Example: ` dws edu-group batch create-student-groups
dws edu-group batch create-student-groups -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
"input": map[string]any{},
})
},
}
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_create_class_group",
CanonicalPath: "edu-group.batch_create_class_group",
CLIPath: "edu-group batch create-student-groups",
PrimaryCLIPath: "edu-group batch create-student-groups",
},
Description: "批量创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为组织下符合条件的班级批量创建师生群",
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
AvoidWhen: []string{"单个班级创建用 student-group create"},
Examples: []string{
"dws edu-group batch create-student-groups --format json",
},
},
Parameters: []contract.ParamDecl{},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// student-group flags
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
// class-group flags
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
// batch flags
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
studentGroupCmd.AddCommand(
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
studentGroupIsInCmd, studentGroupConversationCmd,
studentGroupCreateCmd, studentGroupDisbandCmd,
)
classGroupCmd.AddCommand(
classGroupConversationIDCmd, classGroupConversationCmd,
classGroupExistsCmd, classGroupListByConversationIDsCmd,
)
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
return root
}
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
// if the flag is empty or not a valid integer.
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
func eduGroupParseCSV(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
result = append(result, v)
}
}
return result
}
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
func eduGroupParseIntCSV(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
return result, nil
}
+366
View File
@@ -0,0 +1,366 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// withEduGroupCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"student-group", "info", "--dept-id", "123"},
{"student-group", "exists", "--dept-id", "123"},
{"student-group", "members", "--dept-id", "123"},
{"student-group", "is-in", "--dept-id", "123"},
{"student-group", "conversation", "--dept-id", "123"},
{"student-group", "create", "--dept-id", "123"},
{"student-group", "disband", "--dept-id", "123"},
{"class-group", "conversation-id", "--dept-id", "123"},
{"class-group", "conversation", "--dept-id", "123"},
{"class-group", "exists", "--dept-id", "123"},
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
{"batch", "check-student-group", "--class-ids", "1,2"},
{"batch", "get-class-groups", "--class-ids", "1,2"},
{"batch", "create-student-groups"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
// Each dept-id command must reject an absent --dept-id, covering its own
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
deptIDCommands := [][]string{
{"student-group", "info"},
{"student-group", "exists"},
{"student-group", "members"},
{"student-group", "is-in"},
{"student-group", "conversation"},
{"student-group", "create"},
{"student-group", "disband"},
{"class-group", "conversation-id"},
{"class-group", "conversation"},
{"class-group", "exists"},
}
for _, args := range deptIDCommands {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
t.Run("non-integer dept-id", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("non-integer dept-id error = %v", err)
}
})
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("missing conversation-ids error = %v", err)
}
})
t.Run("list-by-cids only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("empty conversation-ids error = %v", err)
}
})
t.Run("batch check missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("invalid integer class-ids error = %v", err)
}
})
t.Run("batch get missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch get only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("empty class-ids error = %v", err)
}
})
}
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduGroupParseCSV = %#v", got)
}
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
}
}
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
// exercise the full dispatch path through deps.Caller.CallTool.
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "get_class_group_info" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(123) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
}
})
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "create_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(456) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
}
})
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "disband_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(789) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
}
})
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "list_groups_by_conversation_ids" {
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
cids, ok := input["conversationIds"].([]string)
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
}
})
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "batch_check_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
classIDs, ok := input["classIds"].([]int64)
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
}
})
}
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduGroupConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduGroupCommand())
return root
}
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"student-group disband",
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
"disband_class_group",
map[string]any{"deptId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-group" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+145 -2
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
@@ -134,6 +136,13 @@ func WrapErrorWithOperation(err error, operation string) error {
if _, ok := err.(*PATError); ok {
return err
}
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
if apperrors.IsConfirmationRequired(err) {
return err
}
msg := err.Error()
// File lock timeout (cross-process lock)
@@ -254,6 +263,24 @@ func WrapErrorWithOperation(err error, operation string) error {
}
}
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
// 透传服务端原始错误信息。
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
errMsg := msg
var body map[string]any
if json.Unmarshal([]byte(msg), &body) == nil {
errMsg = businessErrorDisplayMessage(body, msg)
}
return &CLIError{
Code: CodeMCPToolError,
Message: errMsg,
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
Operation: operation,
Cause: err,
}
}
// Resource not found
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
suggestion := "Check if the resource exists or if your account has permission"
@@ -379,21 +406,98 @@ func suggestForBusinessErrorText(text string) string {
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
strings.Contains(text, "成员不存在"):
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
strings.Contains(text, "FORBIDDEN"):
return permissionDeniedSuggestion(text)
default:
return ""
}
}
// documentPermissionServerCodes are permission-denied codes that are
// demonstrably drive-specific (their identity carries the forbidden.* domain
// prefix). Only these (plus the document/wiki role-threshold message wording)
// justify the dws drive permission apply-* guidance. Generic code names such
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
// (get-self-setting) and event-subscription tools have been observed returning
// NO_PERMISSION, so keying guidance on it would mislead non-document products
// exactly the way this classification tries to avoid.
var documentPermissionServerCodes = map[string]bool{
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// documentPermissionErrorText reports whether a permission-denied message is
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
// threshold wording emitted by the member-management APIs or the document
// permission error codes embedded in the message text.
func documentPermissionErrorText(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
return strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
}
// isDocumentPermissionError reports whether a permission-denied body is
// specific to document/wiki access, where applying for access via
// dws drive permission apply-* is the actionable next step.
func isDocumentPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
return true
}
}
return documentPermissionErrorText(businessErrorMessage(body))
}
// genericPermissionSuggestion is the product-neutral suggestion for
// permission-denied errors that are not specific to document/wiki access.
const genericPermissionSuggestion = "Verify your account has permission for this resource"
// permissionDeniedSuggestion picks between document apply-permission guidance
// and a product-neutral hint: only document/wiki-specific errors point at
// dws drive permission apply-*, so other products never get a misleading
// document-permission suggestion.
func permissionDeniedSuggestion(text string) string {
if documentPermissionErrorText(text) {
return permissionApplyGuidance
}
return genericPermissionSuggestion
}
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
// body: document/wiki-specific errors get the apply-permission guidance; other
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
// or fall back to the product-neutral hint.
func permissionDeniedSuggestionFor(body map[string]any) string {
if isDocumentPermissionError(body) {
return permissionApplyGuidance
}
if suggestion := suggestForBusinessError(body); suggestion != "" {
return suggestion
}
return genericPermissionSuggestion
}
// ClassifyToolResultContent checks a raw MCP tool result content map for
// DWS gateway auth errors and PAT permission error codes. This is used as the
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
// a typed error before its generic business-error classification.
//
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
// no-permission guidance.
func ClassifyToolResultContent(content map[string]any) error {
if _, ok := getDWSGatewayErrorCode(content); ok {
raw, _ := json.Marshal(content)
@@ -408,6 +512,20 @@ func ClassifyToolResultContent(content map[string]any) error {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
// Permission-denied business errors: surface apply-permission guidance before
// the framework's generic rendering swallows it. Guidance is limited to
// document/wiki-specific signals (forbidden.* codes or role-threshold
// wording); other products keep the product-neutral suggestion.
if isNoPermissionError(content) {
suggestion := permissionDeniedSuggestionFor(content)
raw, _ := json.Marshal(content)
return &CLIError{
Code: CodeAuthPermission,
Message: businessErrorDisplayMessage(content, string(raw)),
Suggestion: suggestion,
}
}
return nil
}
@@ -419,6 +537,23 @@ var patNoPermissionCodes = map[string]bool{
"PAT_HIGH_RISK_NO_PERMISSION": true,
}
// noPermissionServerCodes are business-level permission-denied codes (from the
// MCP server / gateway) that should surface apply-permission guidance instead of
// being swallowed by the framework's generic business-error rendering.
var noPermissionServerCodes = map[string]bool{
"NO_PERMISSION": true,
"FORBIDDEN": true,
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// permissionApplySteps lists the two-step commands to apply for document access.
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
// Returns a typed error for known gateway auth failures, PAT interceptions,
// and business-level errors embedded in HTTP-200 JSON bodies.
@@ -452,10 +587,18 @@ func ClassifyMCPResponseText(text string) error {
}
}
if isNoPermissionError(body) {
return &CLIError{
Code: CodeAuthPermission,
Message: text,
Suggestion: permissionDeniedSuggestionFor(body),
}
}
if isBusinessError(body) {
return &CLIError{
Code: CodeMCPToolError,
Message: text,
Message: businessErrorDisplayMessage(body, text),
Suggestion: suggestForBusinessError(body),
}
}
+164 -8
View File
@@ -123,10 +123,19 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
"User has no permission to access this email": "mailbox list",
"频率超限": "rate limit",
"rate limit": "rate limit",
"用户不存在": "--members",
"不属于当前组织": "--members",
"成员不存在": "--members",
"未找到指定工具": "未注册",
"MCP不存在": "未注册",
"参数错误": "input parameters",
"param error": "input parameters",
// 非文档类权限文本不再给出 drive permission apply 指引。
"无权限访问": "Verify your account",
"权限不足": "Verify your account",
// 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。
"您没有权限访问该文档 (forbidden.no.auth)": "权限申请",
"无权限访问:需要您具备 MANAGER 及以上角色": "权限申请",
}
for input, want := range suggestions {
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
@@ -152,17 +161,76 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
t.Errorf("PAT key %s classified as %#v", key, err)
}
}
// forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED
// 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。
for _, key := range []string{"code", "errorCode", "server_error_code"} {
err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err)
continue
}
if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion)
}
}
// 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。
for _, code := range []string{"forbidden.no.auth"} {
err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err)
}
}
// 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试):
// 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。
nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"})
if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc)
}
// NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。
docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo)
}
// 非文档产品的权限错误保留产品专属建议(邮件)或通用提示,
// 不得被改写为 drive 文档权限申请指引。
mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"})
if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail)
}
generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"})
if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic)
}
for _, tc := range []struct {
text string
kind string
text string
kind string
suggHas string
}{
{"not-json", "nil"},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
{`{"error":"Missing service_id or access_key"}`, "cli"},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
{`{"success":true}`, "nil"},
{"not-json", "nil", ""},
{"null", "nil", ""},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""},
{`{"error":"Missing service_id or access_key"}`, "cli", ""},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""},
{`{"success":false,"errorMsg":"rate limit"}`, "cli", ""},
{`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"},
// 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持
// 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。
{`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"},
{`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"},
// 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。
{`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""},
{`{"success":true}`, "nil", ""},
} {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
@@ -174,6 +242,15 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
if _, ok := err.(*CLIError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
}
case "authperm":
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err)
continue
}
if !strings.Contains(cli.Suggestion, tc.suggHas) {
t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas)
}
case "pat":
if _, ok := err.(*PATError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
@@ -235,3 +312,82 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
t.Fatalf("stripClassFields slice = %s", got)
}
}
// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577:
// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。
func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) {
if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" {
t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first")
}
want := "members[0].id 对应的用户不存在"
if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want {
t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want)
}
if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" {
t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second")
}
if msg := businessErrorMessage(map[string]any{}); msg != "" {
t.Errorf("businessErrorMessage(empty) = %q, want empty", msg)
}
}
// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。
func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) {
got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw")
if got != "权限不足 (logId: abc123)" {
t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got)
}
// errorCode/code 存在时附加在后,保证后端错误码可见
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw")
if got != "失败 (code: forbidden.document.sizeOverLimit)" {
t.Errorf("businessErrorDisplayMessage = %q, want code appended", got)
}
got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw")
if got != "失败 (code: forbidden.accessDenied, logId: l1)" {
t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got)
}
// logId 已包含在 message 中时不重复追加
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw")
if got != "失败 logId:abc123" {
t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got)
}
// 无任何 message 字段时回退 rawText
if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" {
t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got)
}
}
// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」
// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。
func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) {
suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"})
if !strings.Contains(suggestion, "--members") {
t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion)
}
for _, msg := range []string{
"用户不存在",
"不属于当前组织",
"成员不存在",
} {
cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError)
if !ok {
t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg)
}
if cli.Code != CodeMCPToolError {
t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError)
}
if !strings.Contains(cli.Suggestion, "corpId") {
t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion)
}
}
// JSON 体形式:透传 errorMessage 且附 logId
cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError)
if !ok {
t.Fatal("WrapErrorWithOperation(json) not a CLIError")
}
if cli.Message != "用户不存在 (logId: lid-1)" {
t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message)
}
}
+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",
+128 -14
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
@@ -255,7 +290,11 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
}
if isBusinessError(errBody) {
return "", &CLIError{
Code: CodeMCPToolError,
Code: CodeMCPToolError,
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
// (如 drive list --depth 的限流重试)的输入,下游会从
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
Message: c.Text,
Suggestion: suggestForBusinessError(errBody),
}
@@ -440,6 +479,20 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
// 尝试将返回文本解析为 JSON,进行错误分类
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
// errBody == nil 表示文本为 "null",服务端返回了空响应。
// 仅对已确认“空响应=写成功”契约的 permission/member update/remove
// 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的
// 合法 null 保持原样输出,不改变未版本化的机器输出契约。
if errBody == nil {
if nullOnSuccessTools[toolName] {
if deps.Caller.Format() == "json" {
return printJSON(map[string]any{})
}
deps.Out.PrintRaw("{}")
return nil
}
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
}
// 网关层错误(如 token 过期)
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
@@ -454,7 +507,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
}
// 业务逻辑错误
if isBusinessError(errBody) {
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
}
}
@@ -465,6 +518,18 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
return printJSON(result)
}
// nullOnSuccessTools lists MCP tools whose server contract is confirmed to
// return a literal JSON null for successful no-payload writes (permission /
// member update/remove). Only these get the null→{} adaptation; every other
// tool keeps its raw null output so the shared machine-output contract stays
// unchanged.
var nullOnSuccessTools = map[string]bool{
"update_permission": true,
"remove_permission": true,
"update_member": true,
"remove_member": true,
}
// RenderLegacyMCPText renders an already-fetched MCP text response through the
// exact legacy formatter. It lets dual validation execute the business request
// once, validate a shadow unified result, and still preserve legacy bytes.
@@ -765,15 +830,64 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
return suggestForBusinessErrorText(businessErrorMessage(body))
}
// businessErrorMessage extracts the human-readable message from a parsed error
// body, checking errorMsg > errorMessage > message > error. Returns "" if none present.
func businessErrorMessage(body map[string]any) string {
for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} {
if v, ok := body[k].(string); ok && v != "" {
return v
}
}
return suggestForBusinessErrorText(msg)
return ""
}
// businessErrorDisplayMessage extracts the human-readable message from a parsed
// error body, appends the backend error code and logId if present (for
// traceability), and falls back to rawText if no message field is found.
func businessErrorDisplayMessage(body map[string]any, rawText string) string {
msg := businessErrorMessage(body)
if msg == "" {
return rawText
}
var extras []string
for _, k := range []string{"errorCode", "error_code", "code"} {
if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) {
extras = append(extras, "code: "+code)
break
}
}
if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) {
extras = append(extras, "logId: "+logId)
}
if len(extras) > 0 {
msg = msg + " (" + strings.Join(extras, ", ") + ")"
}
return msg
}
// isNoPermissionError reports whether a parsed error body represents a
// permission-denied error, by known server codes or message text. Used to
// surface apply-permission guidance before the framework's generic rendering.
func isNoPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && noPermissionServerCodes[code] {
return true
}
}
msg := businessErrorMessage(body)
if msg == "" {
return false
}
lower := strings.ToLower(msg)
return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") ||
strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") ||
strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") ||
strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色")
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+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",
+738
View File
@@ -0,0 +1,738 @@
package helpers
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
)
// 同步自闭源 MR 28965577(知识库/节点权限增删改查改造):
// 权限/成员 add/update/remove 支持 --members 新格式(USER/DEPT/CONVERSATION/TAG),
// list 支持 nextToken 翻页。以下用例覆盖 CLI → MCP 参数装配契约。
func TestCollectMembersParsesNewFormat(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--members", `[{"type":"USER","id":"u1","roleId":"reader","corpId":"c1"},{"type":"TAG","id":"t1","roleId":"editor","corpId":"c1"}]`,
"--notify=false"); err != nil {
t.Fatalf("permission add --members: %v", err)
}
if caller.tool != "add_permission" {
t.Fatalf("tool=%q", caller.tool)
}
members, ok := caller.args["members"].([]map[string]any)
if !ok || len(members) != 2 {
t.Fatalf("members=%#v", caller.args["members"])
}
if members[0]["roleId"] != "READER" || members[1]["roleId"] != "EDITOR" {
t.Fatalf("roleId normalize failed: %#v", members)
}
if notify, ok := caller.args["notify"].(bool); !ok || notify {
t.Fatalf("notify should be false when --notify=false: %#v", caller.args["notify"])
}
}
func TestCollectMembersValidation(t *testing.T) {
cases := []struct {
name string
members string
wantError string
}{
{"invalid json", "[{", "JSON 解析失败"},
{"empty array", "[]", "不能为空数组"},
{"missing type", `[{"id":"u1","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 type"},
{"missing id", `[{"type":"USER","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 id"},
{"missing corpId", `[{"type":"USER","id":"u1","roleId":"READER"}]`, "需携带 corpId"},
{"missing roleId", `[{"type":"USER","id":"u1","corpId":"c1"}]`, "缺少必填字段 roleId"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", "--members", tc.members)
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("expected error containing %q, got %v", tc.wantError, err)
}
})
}
}
func TestValidateMembersExclusivity(t *testing.T) {
t.Run("members and users are mutually exclusive", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--users", "u1", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "互斥") {
t.Fatalf("expected mutual exclusion error, got %v", err)
}
})
t.Run("one of members or users is required", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1")
if err == nil || !strings.Contains(err.Error(), "之一") {
t.Fatalf("expected required error, got %v", err)
}
})
t.Run("role is redundant with members", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--role", "READER", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "不需要 --role") {
t.Fatalf("expected no-role error, got %v", err)
}
})
}
func TestPermissionListPagination(t *testing.T) {
t.Run("drive permission list passes nextToken and pageSize", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1",
"--limit", "50", "--next-token", "50"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.tool != "list_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != 50 {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
if caller.args["nextToken"] != "50" {
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
}
})
t.Run("doc permission list passes nextToken", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "permission", "list", "--node", "n1",
"--next-token", "30"); err != nil {
t.Fatalf("doc permission list: %v", err)
}
if caller.args["nextToken"] != "30" {
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
}
})
t.Run("wiki member list passes nextToken and pageSize", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newWikiCommand(), "member", "list", "--workspace", "ws1",
"--limit", "50", "--next-token", "50"); err != nil {
t.Fatalf("wiki member list: %v", err)
}
if caller.tool != "list_member" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != 50 || caller.args["nextToken"] != "50" {
t.Fatalf("args=%#v", caller.args)
}
})
}
func TestPermissionRemoveWithMembers(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
// remove 是 destructive 入口:Safety confirmation=user_required,测试里
// 注入根级 --yes 后直接确认执行(参数装配本身由下方断言验证)。
root := newDriveCommand()
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
if err := executePR868Command(t, root, "permission", "remove", "--node", "n1",
"--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes"); err != nil {
t.Fatalf("permission remove --members: %v", err)
}
if caller.tool != "remove_permission" {
t.Fatalf("tool=%q", caller.tool)
}
members, ok := caller.args["members"].([]map[string]any)
if !ok || len(members) != 1 || members[0]["id"] != "cid1" {
t.Fatalf("members=%#v", caller.args["members"])
}
// remove 语义下 roleId 不应被要求
if _, hasRole := members[0]["roleId"]; hasRole {
t.Fatalf("remove members should not require roleId: %#v", members[0])
}
}
func TestWikiMemberAddWithMembersRejectsOwner(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newWikiCommand(), "member", "add", "--workspace", "ws1",
"--members", `[{"type":"USER","id":"u1","roleId":"OWNER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "OWNER") {
t.Fatalf("expected OWNER rejection, got %v", err)
}
}
// ──────────────────────────────────────────────────────
// collectMembers / validateMembersExclusivity 单元级用例
// 同步自内部 MR 28965577 补齐的覆盖:四类型混传、remove 语义、
// 超过 30 个、corpId 按类型校验、--user 别名。
// ──────────────────────────────────────────────────────
// newMembersCmd 构造带 --members/--users/--user/--role 的最小命令,
// 与生产命令的 flag 注册保持一致。
func newMembersCmd() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("members", "", "")
cmd.Flags().String("users", "", "")
cmd.Flags().String("user", "", "")
cmd.Flags().String("role", "", "")
return cmd
}
func TestCollectMembersMixedTypes(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"MANAGER","corpId":"c1"},{"type":"DEPT","id":"d1","roleId":"editor","corpId":"c1"},{"type":"CONVERSATION","id":"cid1","roleId":"READER"},{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 4 {
t.Fatalf("expected 4 members, got %d", len(got))
}
if got[1]["roleId"] != "EDITOR" {
t.Errorf("member[1] roleId should be normalized to EDITOR, got %v", got[1]["roleId"])
}
if got[0]["corpId"] != "c1" {
t.Errorf("corpId should be preserved, got %v", got[0]["corpId"])
}
}
func TestCollectMembersRemoveSemantics(t *testing.T) {
t.Run("remove only needs type and id", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"},{"type":"DEPT","id":"d1","corpId":"c1"}]`)
got, err := collectMembers(cmd, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 members, got %d", len(got))
}
})
t.Run("add still requires roleId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for missing roleId, got nil")
}
})
}
func TestCollectMembersOver30(t *testing.T) {
cmd := newMembersCmd()
members := "["
for i := 0; i < 31; i++ {
if i > 0 {
members += ","
}
members += fmt.Sprintf(`{"type":"USER","id":"u%d","roleId":"READER","corpId":"c"}`, i)
}
members += "]"
_ = cmd.Flags().Set("members", members)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for >30 members, got nil")
}
}
func TestCollectMembersCorpIDByType(t *testing.T) {
t.Run("DEPT requires corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"DEPT","id":"d1","roleId":"EDITOR"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for DEPT missing corpId, got nil")
}
})
t.Run("TAG requires corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for TAG missing corpId, got nil")
}
})
t.Run("CONVERSATION works without corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"CONVERSATION","id":"cid1","roleId":"READER"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error for CONVERSATION without corpId: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 member, got %d", len(got))
}
})
t.Run("TAG with corpId passes", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 member, got %d", len(got))
}
})
}
func TestValidateMembersExclusivityUserAlias(t *testing.T) {
t.Run("--user alias counts as users", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("user", "u1")
if err := validateMembersExclusivity(cmd); err != nil {
t.Errorf("unexpected error for --user alias: %v", err)
}
})
t.Run("--user alias conflicts with --members", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("user", "u1")
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err := validateMembersExclusivity(cmd); err == nil {
t.Error("expected error when both --user and --members set, got nil")
}
})
}
// ──────────────────────────────────────────────────────
// isNoPermissionError — 新接口 forbidden.accessDenied 错误码
// ──────────────────────────────────────────────────────
func TestIsNoPermissionErrorAccessDenied(t *testing.T) {
t.Run("server code forbidden.accessDenied", func(t *testing.T) {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
body := map[string]any{key: "forbidden.accessDenied"}
if !isNoPermissionError(body) {
t.Errorf("isNoPermissionError(%s=forbidden.accessDenied) = false, want true", key)
}
}
})
t.Run("access-denied message text", func(t *testing.T) {
for _, msg := range []string{
"需要您具备 EDITOR 及以上角色",
"需要您具备 MANAGER 及以上角色才能执行此操作",
"forbidden.accessDenied",
"Forbidden.AccessDenied",
} {
body := map[string]any{"errorMsg": msg}
if !isNoPermissionError(body) {
t.Errorf("isNoPermissionError(msg=%q) = false, want true", msg)
}
}
})
t.Run("normal body is not permission error", func(t *testing.T) {
body := map[string]any{"success": true, "errorMsg": ""}
if isNoPermissionError(body) {
t.Error("isNoPermissionError(success body) = true, want false")
}
})
}
// ──────────────────────────────────────────────────────
// --notify bool flag 解析(NoArgs 防御 `--notify false` 空格形式)
// ──────────────────────────────────────────────────────
// newNotifyCmd 构造与生产 add/update 命令一致的最小命令:
// --notify bool flag + Args: cobra.NoArgs。
func newNotifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmd.Flags().Bool("notify", false, "")
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func TestNotifyFlagParsing(t *testing.T) {
t.Run("equals form sets false", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{"--notify=false"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, _ := cmd.Flags().GetBool("notify"); got {
t.Error("--notify=false: got true, want false")
}
if !cmd.Flags().Changed("notify") {
t.Error("--notify=false should mark flag as changed")
}
})
t.Run("bare flag defaults true via NoOptDefVal", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{"--notify"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, _ := cmd.Flags().GetBool("notify"); !got {
t.Error("--notify (no value): got false, want true (NoOptDefVal)")
}
if !cmd.Flags().Changed("notify") {
t.Error("--notify should mark flag as changed")
}
})
t.Run("space form rejected by NoArgs", func(t *testing.T) {
cmd := newNotifyCmd()
// "--notify false" 被 pflag 解析为 notify=true(NoOptDefVal)+ 位置参数 "false"。
// Args: cobra.NoArgs 拒绝位置参数,防止用户误以为传了 false。
cmd.SetArgs([]string{"--notify", "false"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for '--notify false' (space form) with NoArgs, got nil")
}
if got, _ := cmd.Flags().GetBool("notify"); !got {
t.Error("pflag should set notify=true via NoOptDefVal for bare --notify, got false")
}
})
t.Run("omitted stays unchanged", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.Flags().Changed("notify") {
t.Error("notify should not be marked changed when omitted")
}
if got, _ := cmd.Flags().GetBool("notify"); got {
t.Error("omitted notify: got true, want false (default)")
}
})
}
// ──────────────────────────────────────────────────────
// pageSize 1..50 运行时边界(P2 意见:服务端 pageSize 上限 50)
// ──────────────────────────────────────────────────────
func newPageSizeCmd(changed string, value int) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("limit", 30, "")
cmd.Flags().Int("max-results", 0, "")
_ = cmd.Flags().Set(changed, fmt.Sprintf("%d", value))
return cmd
}
func TestPermissionPageSizeFromFlagsBoundaries(t *testing.T) {
t.Run("rejects out-of-range values", func(t *testing.T) {
for _, flag := range []string{"limit", "max-results"} {
for _, size := range []int{-1, 0, 51, 100} {
if _, _, err := permissionPageSizeFromFlags(newPageSizeCmd(flag, size)); err == nil {
t.Errorf("--%s %d: expected error, got nil", flag, size)
}
}
}
})
t.Run("accepts in-range values", func(t *testing.T) {
for _, size := range []int{1, 30, 50} {
got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", size))
if err != nil {
t.Errorf("--limit %d: unexpected error %v", size, err)
continue
}
if !ok || got != size {
t.Errorf("--limit %d: got (%d, %v)", size, got, ok)
}
}
})
t.Run("omitted returns ok=false", func(t *testing.T) {
// newPageSizeCmd 强制 Set(changed),先验证 Set 后 Changed 生效,
// 再用未设置任何 flag 的命令验证默认路径。
if got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", 30)); err != nil || !ok || got != 30 {
t.Fatalf("setup sanity failed: got (%d, %v, %v), want (30, true, nil)", got, ok, err)
}
fresh := &cobra.Command{Use: "test"}
fresh.Flags().Int("limit", 30, "")
fresh.Flags().Int("max-results", 0, "")
if got, ok, err := permissionPageSizeFromFlags(fresh); err != nil || ok || got != 0 {
t.Errorf("omitted flags: got (%d, %v, %v), want (0, false, nil)", got, ok, err)
}
})
}
func TestPermissionListPageSizeRuntimeGuard(t *testing.T) {
type listCase struct {
name string
root func() *cobra.Command
path []string
nodeID []string
}
cases := []listCase{
{"drive", newDriveCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
{"doc", newDocCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
{"wiki", newWikiCommand, []string{"member", "list"}, []string{"--workspace", "ws1"}},
}
for _, lc := range cases {
t.Run(lc.name+" rejects --limit 51", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.nodeID...)
args = append(args, "--limit", "51")
err := executePR868Command(t, lc.root(), args...)
if err == nil || !strings.Contains(err.Error(), "1..50") {
t.Fatalf("expected 1..50 validation error, got %v", err)
}
if caller.calls != 0 {
t.Fatalf("list should not call MCP with invalid pageSize, called %d times", caller.calls)
}
})
t.Run(lc.name+" accepts --limit 50", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.nodeID...)
args = append(args, "--limit", "50")
if err := executePR868Command(t, lc.root(), args...); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if caller.args["pageSize"] != 50 {
t.Fatalf("pageSize=%#v, want 50", caller.args["pageSize"])
}
})
}
}
// ──────────────────────────────────────────────────────
// add 的 --notify 声明默认值必须与实际透传行为一致
//
// 三个 add 命令都用 `if Flags().Changed("notify")` 守卫来决定是否把 notify
// 写入 toolArgs,所以省略该 flag 时字段根本不会发给服务端,实测服务端按
// 不通知处理。声明默认值因此必须是 false —— 若有人把它改回 true,help 会
// 承诺一个从未透传的默认值(曾经的真实缺陷)。
// ──────────────────────────────────────────────────────
func TestPermissionAddNotifyDefaultMatchesWireBehavior(t *testing.T) {
type addCase struct {
name string
root func() *cobra.Command
path []string
target []string
}
cases := []addCase{
{"drive", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
{"doc", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
{"wiki", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}},
}
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
runAdd := func(t *testing.T, lc addCase, extra ...string) *scriptedToolCaller {
t.Helper()
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.target...)
args = append(args, "--members", members)
args = append(args, extra...)
if err := executePR868Command(t, lc.root(), args...); err != nil {
t.Fatalf("unexpected error: %v", err)
}
return caller
}
for _, lc := range cases {
t.Run(lc.name+" omitted sends no notify field", func(t *testing.T) {
caller := runAdd(t, lc)
if v, present := caller.args["notify"]; present {
t.Fatalf("notify must be absent from toolArgs when --notify is omitted, got %#v", v)
}
})
t.Run(lc.name+" declared default is false", func(t *testing.T) {
// 声明默认值必须与上面的透传行为一致,否则 help 在说谎。
root := lc.root()
leaf, _, err := root.Find(lc.path)
if err != nil {
t.Fatalf("find %v: %v", lc.path, err)
}
flag := leaf.Flags().Lookup("notify")
if flag == nil {
t.Fatal("notify flag not declared")
}
if flag.DefValue != "false" {
t.Errorf("notify DefValue=%q, want \"false\": 省略时不透传该字段,声明 true 会承诺一个未生效的默认值", flag.DefValue)
}
})
t.Run(lc.name+" bare --notify opts in", func(t *testing.T) {
caller := runAdd(t, lc, "--notify")
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
}
})
t.Run(lc.name+" --notify=false is sent explicitly", func(t *testing.T) {
caller := runAdd(t, lc, "--notify=false")
notify, ok := caller.args["notify"].(bool)
if !ok || notify {
t.Fatalf("notify=%#v, want explicit false", caller.args["notify"])
}
})
}
}
// ──────────────────────────────────────────────────────
// 服务端对已确认“空响应=写成功”契约的 permission/member update/remove
// 工具可能返回字面 "null"(操作成功但无返回数据)。CLI 仅对这几个
// 工具将其渲染为空对象 {},避免 Agent/下游把 null 当作对象解析时失败;
// 其它工具的合法 null 保持原样输出,公共渲染器的机器输出契约不变。
// ──────────────────────────────────────────────────────
func TestCrossPlatformCoverageNullToolResponseRendersEmptyObject(t *testing.T) {
// nullOnSuccessTools 集合内:null 适配为 {}
for _, format := range []string{"json", "raw"} {
t.Run("adapted_"+format, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
installScriptedCaller(t, caller)
out := &bytes.Buffer{}
deps.Out.w = out
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_permission", nil, false); err != nil {
t.Fatalf("null tool response should render as {}: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "{}" {
t.Errorf("format %s renders %q, want {}", format, got)
}
})
}
// 集合外的工具:null 原样输出,不再被公共渲染路径改写为 {}
for _, format := range []string{"json", "raw"} {
t.Run("passthrough_"+format, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
installScriptedCaller(t, caller)
out := &bytes.Buffer{}
deps.Out.w = out
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_node_permission", nil, false); err != nil {
t.Fatalf("null passthrough should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "null" {
t.Errorf("format %s renders %q, want null unchanged", format, got)
}
})
}
}
// ──────────────────────────────────────────────────────
// update / remove 的 --members 装配与 --users 解析失败分支
//
// add 的 members+notify 透传已有
// TestPermissionAddNotifyDefaultMatchesWireBehavior 覆盖;这里补齐
// update(members + notify)与 doc/wiki remove(members)的同构分支,
// 以及各产品 add/update/remove 中 collectUserIDs 的错误路径:
// --users 传纯空白时 flagOrFallback 视为已提供(非空字符串),
// 但 parseCommentMentionIds 过滤空白后为空,collectUserIDs 必须报错
// 而不是向服务端发送空 userIds。
// ──────────────────────────────────────────────────────
func TestCrossPlatformCoveragePermissionUpdateWithMembers(t *testing.T) {
type updateCase struct {
name string
root func() *cobra.Command
path []string
target []string
tool string
}
cases := []updateCase{
{"drive", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
{"doc", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
{"wiki", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, "update_member"},
}
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
for _, uc := range cases {
t.Run(uc.name+" passes members and notify", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, uc.path...), uc.target...)
args = append(args, "--members", members, "--notify")
if err := executePR868Command(t, uc.root(), args...); err != nil {
t.Fatalf("update --members: %v", err)
}
if caller.tool != uc.tool {
t.Fatalf("tool=%q, want %q", caller.tool, uc.tool)
}
got, ok := caller.args["members"].([]map[string]any)
if !ok || len(got) != 1 || got[0]["id"] != "u1" || got[0]["roleId"] != "READER" {
t.Fatalf("members=%#v", caller.args["members"])
}
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
}
})
}
}
func TestCrossPlatformCoveragePermissionRemoveWithMembersProducts(t *testing.T) {
// drive remove 已由 TestPermissionRemoveWithMembers 覆盖;这里补 doc/wiki。
type removeCase struct {
name string
root func() *cobra.Command
path []string
target []string
tool string
}
cases := []removeCase{
{"doc", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, "remove_permission"},
{"wiki", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, "remove_member"},
}
for _, rc := range cases {
t.Run(rc.name+" passes members", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
// remove 需用户确认(confirmation=user_required):注入根级 --yes。
root := rc.root()
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
args := append(append([]string{}, rc.path...), rc.target...)
args = append(args, "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes")
if err := executePR868Command(t, root, args...); err != nil {
t.Fatalf("remove --members: %v", err)
}
if caller.tool != rc.tool {
t.Fatalf("tool=%q, want %q", caller.tool, rc.tool)
}
got, ok := caller.args["members"].([]map[string]any)
if !ok || len(got) != 1 || got[0]["id"] != "cid1" {
t.Fatalf("members=%#v", caller.args["members"])
}
})
}
}
func TestCrossPlatformCoveragePermissionUsersBlankParseError(t *testing.T) {
type blankCase struct {
name string
root func() *cobra.Command
path []string
target []string
needRole bool
}
cases := []blankCase{
{"drive add", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
{"drive update", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
{"drive remove", newDriveCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
{"doc add", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
{"doc update", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
{"doc remove", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
{"wiki add", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}, true},
{"wiki update", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, true},
{"wiki remove", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, false},
}
for _, bc := range cases {
t.Run(bc.name+" rejects blank --users", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, bc.path...), bc.target...)
args = append(args, "--users", " ")
if bc.needRole {
// add/update 的旧格式分支在 collectUserIDs 之前校验必填 --role。
args = append(args, "--role", "READER")
}
err := executePR868Command(t, bc.root(), args...)
if err == nil || !strings.Contains(err.Error(), "--users is required") {
t.Fatalf("expected --users is required error, got %v", err)
}
if caller.calls != 0 {
t.Fatalf("MCP must not be called when --users resolves empty, called %d times", caller.calls)
}
})
}
}

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