Compare commits

...
Author SHA1 Message Date
Dennis 446f27d96e fix(doc): trust only explicit inserted block IDs 2026-08-14 10:14:20 +08:00
Dennis ef81a1a59c test(doc): complete readback coverage evidence 2026-08-14 10:14:17 +08:00
Dennis d8ed8c2147 fix(doc): verify inline media from jsonml readback 2026-08-14 10:14:14 +08:00
Dennis f3ae03373e fix(doc): harden mutation readback verification 2026-08-14 10:14:11 +08:00
github-actions[bot] f55f9bc3a6 Merge pull request #998 from DingTalk-Real-AI/codex/open-dingtalk-id-format-routing
fix(chat): harden openDingTalkId target routing
2026-08-14 01:48:08 +08:00
栩朝 be001949e4 test(chat): complete sender routing coverage 2026-08-14 01:31:16 +08:00
栩朝 bcd91aca1f test(chat): align time defaults with current open ID format 2026-08-14 01:10:17 +08:00
栩朝 12e6632692 fix(chat): preserve sender identity uncertainty 2026-08-14 01:01:53 +08:00
栩朝 a5111f486b fix(chat): harden openDingTalkId target routing 2026-08-14 01:01:53 +08:00
github-actions[bot] 7a9348f9aa Merge pull request #973 from xlb1130/feat/85378080-chat-message-time-defaults
feat(chat): default message query time ranges
2026-08-14 00:01:32 +08:00
长真 6c78db7467 fix(chat): document Shanghai time message default 2026-08-13 23:37:29 +08:00
xlb1130 b539e15e6d Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 23:29:03 +08:00
github-actions[bot] c9426622f0 Merge pull request #985 from xlb1130/chore/85411130-idempotency-key-ledger
chore(policy): add chat message send idempotency flag ledger
2026-08-13 23:13:51 +08:00
长真 5b01f29f2f Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 22:35:02 +08:00
xlb1130 5efb6210b0 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 22:32:50 +08:00
长真 113e084a8d fix(chat): align default message time timezone 2026-08-13 22:31:57 +08:00
xlb1130 a76492e16e Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 22:16:29 +08:00
github-actions[bot] a0cc9b4b51 Merge pull request #942 from avicii-chen/feat/list-filter
feat(drive): add drive list --type/--start/--end client-side filtering
2026-08-13 14:06:12 +00:00
juanxincai 45df573d0e Merge branch 'main' into feat/list-filter 2026-08-13 21:30:04 +08:00
github-actions[bot] 608edfa309 Merge pull request #974 from DingTalk-Real-AI/fix/param-hallucination
feat(cli): standardize Doc and Drive parameter aliases
2026-08-13 13:17:11 +00:00
长真 e8ef510d3a Merge remote-tracking branch 'origin/chore/85411130-idempotency-key-ledger' into chore/85411130-idempotency-key-ledger 2026-08-13 21:09:19 +08:00
长真 8c6266f158 chore(policy): consume idempotency flag migration 2026-08-13 21:06:44 +08:00
长真 91f0fb7b11 fix(chat): declare idempotency key alias 2026-08-13 21:03:03 +08:00
xlb1130 570d2e6756 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 20:59:30 +08:00
长真 c3ffb9c831 fix(chat): validate list-all time defaults 2026-08-13 20:57:40 +08:00
juanxincai e6821176a4 Merge branch 'main' into feat/list-filter 2026-08-13 20:55:23 +08:00
长真 44857449d6 Merge remote-tracking branch 'upstream/main' into chore/85411130-idempotency-key-ledger 2026-08-13 20:50:03 +08:00
克谨 29f2f1c813 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 20:48:23 +08:00
xlb1130 d21f18af04 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 20:48:16 +08:00
github-actions[bot] dd604455cc Merge pull request #990 from xlb1130/chore/85411130-idempotency-key-ledger-only
chore(policy): add idempotency flag migration ledger
2026-08-13 12:46:25 +00:00
克谨 26049a158a Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 20:41:21 +08:00
xlb1130 95f9d168f1 Merge branch 'main' into chore/85411130-idempotency-key-ledger-only 2026-08-13 20:26:51 +08:00
juanxincai 6d58520f57 Merge branch 'main' into feat/list-filter 2026-08-13 20:18:35 +08:00
github-actions[bot] 91090a13b9 chore: update formula for v1.0.58 [skip ci] 2026-08-13 11:51:41 +00:00
juanxincai 395712490d Merge branch 'main' into feat/list-filter 2026-08-13 19:38:56 +08:00
chichuan 29c00341fa Merge pull request #997 from DingTalk-Real-AI/codex/fix-sealed-stable-compat
fix(ci): preserve delivered stable compatibility baseline
2026-08-13 19:26:10 +08:00
juanxincai 2eef6fdaa2 Merge branch 'main' into feat/list-filter 2026-08-13 19:14:48 +08:00
chichuan 14a2175434 fix(ci): preserve delivered stable compatibility baseline 2026-08-13 19:04:57 +08:00
卷心菜 973671bdf1 chore: trigger auto CR re-review 2026-08-13 18:36:38 +08:00
xlb1130 2d24f74980 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 18:03:28 +08:00
长真 90278ab2fc test(chat): cover message default end window 2026-08-13 18:02:50 +08:00
chichuan 1b06d0105a Merge pull request #995 from DingTalk-Real-AI/codex/changelog-v1.0.58
docs: seal changelog for v1.0.58
2026-08-13 17:53:40 +08:00
chichuan 18fad57bbe docs: seal changelog for v1.0.58 2026-08-13 17:44:56 +08:00
xlb1130 658f1e8e34 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 17:40:20 +08:00
长真 a32608f964 fix(chat): use local time for message defaults 2026-08-13 17:39:32 +08:00
github-actions[bot] c3ef04988b chore: update beta formula for v1.0.58-beta.6 [skip ci] 2026-08-13 09:10:23 +00:00
xlb1130 1f2fbca4de Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 16:34:19 +08:00
john 76d54d6df6 Merge pull request #993 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.6
docs: seal v1.0.58-beta.6 changelog
2026-08-13 16:33:34 +08:00
xlb1130 58a8dddf31 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 16:33:03 +08:00
克谨 0dc6735da2 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 16:25:39 +08:00
chichuan a36189d31e docs: seal v1.0.58-beta.6 changelog 2026-08-13 16:19:04 +08:00
长真 e46c4d0d71 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 16:16:39 +08:00
长真 35f399e2cf fix(chat): use Shanghai time for message defaults 2026-08-13 16:16:00 +08:00
chichuan d52d16dba4 Merge pull request #987 from DingTalk-Real-AI/codex/fix-release-seal-ci-path
ci: fast-path release seal fragment archival
2026-08-13 16:15:00 +08:00
xlb1130 9ff74c852a Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 15:56:17 +08:00
克谨 9be59ddfec Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 15:50:15 +08:00
chichuan d77fa91c69 Merge remote-tracking branch 'origin/main' into codex/fix-release-seal-ci-path 2026-08-13 15:37:08 +08:00
长真 9eeb0681ff test(chat): cover list-all time defaults in platform gate 2026-08-13 15:31:35 +08:00
chichuan 9ea527a7c4 ci: reject truncated release seal file lists 2026-08-13 15:25:11 +08:00
chichuan f78f1b83e7 Merge pull request #991 from typefield/agent/fix-release-validator
fix: align package verifier with Agent skill roots
2026-08-13 15:24:10 +08:00
卷心菜 75bd518447 fix(drive): honor --type folder in --latest top-N and harden filter mutexes 2026-08-13 15:19:46 +08:00
玉澜 3a6fa9a00c Merge remote-tracking branch 'origin/agent/fix-release-validator' into agent/fix-release-validator 2026-08-13 15:04:12 +08:00
玉澜 dc43d0d6d4 Merge remote-tracking branch 'upstream/main' into agent/fix-release-validator 2026-08-13 15:02:03 +08:00
chichuan bb69ed76df Merge branch 'main' into agent/fix-release-validator 2026-08-13 15:01:15 +08:00
chichuan a26b16b30e test: scope release seal env assertions 2026-08-13 14:58:44 +08:00
玉澜 e0c9b4910d fix: align package verifier with Agent skill roots 2026-08-13 14:57:51 +08:00
xlb1130 c118a6a795 Merge branch 'main' into chore/85411130-idempotency-key-ledger-only 2026-08-13 14:48:52 +08:00
xlb1130 3d6c93196a Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 14:44:37 +08:00
长真 dc762dc6e3 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 14:43:07 +08:00
长真 45a80185f6 fix(chat): pass explicit list-all times through 2026-08-13 14:42:26 +08:00
chichuan a1dc997004 Merge branch 'main' into codex/fix-release-seal-ci-path 2026-08-13 14:41:29 +08:00
chichuan b525497da8 fix: pass release seal classification to policy 2026-08-13 14:31:50 +08:00
卷心菜 273a3ab5dd chore: migrate drive list changelog entries to release fragments 2026-08-13 14:12:13 +08:00
卷心菜 647bdb251c test(drive): cover drive list filter/pattern edge branches 2026-08-13 14:12:13 +08:00
卷心菜 9c59206d2f feat(drive): add drive list --type/--start/--end client-side filtering 2026-08-13 14:12:13 +08:00
长真 9edc587e96 chore(policy): to #85411130 add idempotency flag migration ledger 2026-08-13 13:55:53 +08:00
克谨 db2caf6544 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 13:46:08 +08:00
chichuan ea9e31a59f Merge pull request #986 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.5
release: seal v1.0.58-beta.5 changelog
2026-08-13 13:45:14 +08:00
chichuan e58b85ea45 test: cover release seal CI fast path 2026-08-13 13:44:23 +08:00
chichuan e3fef0b6d4 ci: fast-path release seal fragment archival 2026-08-13 13:34:43 +08:00
xlb1130 54535bec11 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 13:29:00 +08:00
长真 d32bbe009d fix(chat): expose idempotency key for message send 2026-08-13 13:28:00 +08:00
chichuan c7236a1844 release: seal v1.0.58-beta.5 changelog 2026-08-13 13:18:21 +08:00
xlb1130 0ea3d9810e Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 13:11:31 +08:00
github-actions[bot] 0a063e3ebd Merge pull request #979 from wxianfeng/feat/85384225-agent-version-ext
feat: forward Agent version and extension context
2026-08-13 05:07:40 +00:00
xlb1130 1e13413f79 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 13:03:01 +08:00
chichuan e19c54f77e Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 12:47:15 +08:00
长真 891dde7d03 Merge branch 'main' into chore/85411130-idempotency-key-ledger 2026-08-13 12:28:59 +08:00
github-actions[bot] fbc34509f8 Merge pull request #970 from DingTalk-Real-AI/codex/im-page-all
feat(chat): unify shortcut auto-pagination controls
2026-08-13 04:18:43 +00:00
长真 def6ed4d2f test(chat): align list-all time expectations 2026-08-13 12:16:16 +08:00
长真 7bf8ce79bd chore(policy): to #85411130 add idempotency flag migration ledger 2026-08-13 12:07:06 +08:00
昊淼 ad0cf639c4 Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 11:49:27 +08:00
Dennis 2f8e136dc0 fix(chat): fail closed on bounded legacy pages 2026-08-13 11:35:39 +08:00
Dennis fdbd11e0ea docs(changelog): add IM pagination release note 2026-08-13 11:35:37 +08:00
Dennis d07bf39586 fix(chat): bound automatic page delays 2026-08-13 11:35:35 +08:00
Dennis eee41a9b45 fix(chat): preserve safe pagination continuations 2026-08-13 11:35:33 +08:00
Dennis 896801634f fix(chat): preserve max-results visibility 2026-08-13 11:35:30 +08:00
Dennis a203572ee3 feat(chat): unify shortcut auto-pagination controls 2026-08-13 11:35:27 +08:00
克谨 ed6e7e493c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 11:33:07 +08:00
github-actions[bot] 6c0ba91414 Merge pull request #963 from DingTalk-Real-AI/codex/drive-readback-verification
fix(drive): verify upload and move readback
2026-08-13 03:26:54 +00:00
长真 ec4a730287 Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-13 10:55:22 +08:00
长真 19a21b8f7e fix(chat): avoid explicit zone in list-all formatting 2026-08-13 10:54:51 +08:00
xlb1130 fa00da3507 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-13 10:53:55 +08:00
昊淼 472d3d321b Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 10:40:21 +08:00
克谨 a7ac4a264e Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-13 10:40:05 +08:00
john 0b68450709 Merge branch 'main' into codex/drive-readback-verification 2026-08-13 10:38:17 +08:00
john 346444ea38 Merge pull request #981 from typefield/fix/interface-integrity-ledger-validation
fix: restore interface migration ledger compatibility
2026-08-13 10:37:25 +08:00
wxianfeng 54dc8fadb7 feat: forward agent version and extension context 2026-08-13 10:13:04 +08:00
玉澜 b469bb127a docs: clarify hidden canonical promotion 2026-08-13 09:37:22 +08:00
玉澜 c6e810e4d9 fix: restore interface migration ledger compatibility 2026-08-13 09:34:48 +08:00
Dennis 98d03455b1 fix(drive): bind readback to requested objects 2026-08-13 00:12:07 +08:00
Dennis fad41d4d99 fix(drive): verify upload and move readback 2026-08-13 00:12:02 +08:00
xlb1130 1a9945f299 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 23:18:30 +08:00
长真 b92ac4db0f fix(chat): preserve list-all time format 2026-08-12 23:16:33 +08:00
克谨 9a3796c401 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 22:46:16 +08:00
克谨 6bf78f1783 test(ci): isolate app race partitions 2026-08-12 22:46:05 +08:00
github-actions[bot] 5fed80fc0f Merge pull request #966 from wxianfeng/feat/85349380-primary-param-governance
feat: support safe Primary flag rename governance (#85349380)
2026-08-12 14:40:01 +00:00
xlb1130 bb68baf0a9 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 22:39:59 +08:00
长真 657f9ee368 ci(test): extend app race shard timeout 2026-08-12 22:29:22 +08:00
昊淼 1727025f67 Merge branch 'main' into feat/85349380-primary-param-governance 2026-08-12 22:23:32 +08:00
克谨 221e42b103 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 21:23:34 +08:00
github-actions[bot] 715f5346da Merge pull request #975 from DingTalk-Real-AI/dws_optimization
fix(skill): clarify document-space routing in doc/drive/wiki descript…
2026-08-12 13:21:57 +00:00
克谨 bcc324cc8f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 21:02:57 +08:00
RuiGong01 f875b1bc87 Merge branch 'main' into dws_optimization 2026-08-12 20:54:37 +08:00
克谨 cf8dd167a4 fix(cli): preserve scoped space aliases 2026-08-12 20:49:57 +08:00
长真 d82e12d09e Merge remote-tracking branch 'origin/feat/85378080-chat-message-time-defaults' into feat/85378080-chat-message-time-defaults 2026-08-12 20:44:37 +08:00
长真 30f3273a17 fix(chat): validate message list-all time range 2026-08-12 20:43:56 +08:00
xlb1130 3e362fb3d1 Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 20:23:20 +08:00
长真 d40a22aeb0 fix(chat): default start from explicit message end 2026-08-12 20:17:10 +08:00
github-actions[bot] 3388df1c63 Merge pull request #978 from xlb1130/feat/85387314-chat-image-guide
docs(chat): clarify image markdown guide
2026-08-12 19:54:03 +08:00
克谨 b29a12abbf test: harden parameter alias safety gates 2026-08-12 19:05:11 +08:00
克谨 65bedd5f8c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 18:35:18 +08:00
xlb1130 d3584077d7 Merge branch 'main' into feat/85387314-chat-image-guide 2026-08-12 18:30:40 +08:00
github-actions[bot] e49ba1ae71 Merge pull request #972 from typefield/feat/zcode-skill-root
feat(skill): support ZCode skill root
2026-08-12 10:20:18 +00:00
长真 2c46213257 docs(chat): to #85387314 clarify image markdown guide 2026-08-12 18:04:17 +08:00
克谨 388ae0d37b ci: shard parameter alias changes 2026-08-12 17:59:54 +08:00
john 77dc7d30a0 Merge branch 'main' into feat/zcode-skill-root 2026-08-12 17:56:45 +08:00
ruigong aa3c279313 chore(policy): align doc skill context budget with event/chat (10000) 2026-08-12 17:55:31 +08:00
克谨 07c5d25d55 fix(cli): cover doc search time aliases 2026-08-12 17:14:23 +08:00
ruigong 51dc3df91b fix(skill): clarify document-space routing in doc/drive/wiki descriptions 2026-08-12 17:14:20 +08:00
克谨 e9bbfdd20c Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-12 17:00:18 +08:00
长真 1f7d8c16bd Merge branch 'main' into feat/85378080-chat-message-time-defaults 2026-08-12 16:42:38 +08:00
长真 9c14d9a6e1 fix(chat): repair message time defaults checks 2026-08-12 16:42:27 +08:00
github-actions[bot] 70e03887d4 Merge pull request #962 from xlb1130/chore/85200556-im-id-flag-migrations-pending
chore(interface): add IM ID flag migration pending approvals
2026-08-12 08:40:45 +00:00
克谨 fd26152141 docs(release): note Doc and Drive parameter aliases 2026-08-12 16:24:03 +08:00
克谨 a53971b146 feat(cli): standardize Doc and Drive parameter aliases 2026-08-12 16:23:17 +08:00
xlb1130 6ac2bbb7cf Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 16:23:15 +08:00
长真 56bb50913b feat(chat): default message query time ranges 2026-08-12 16:23:13 +08:00
github-actions[bot] 5812276f46 Merge pull request #958 from typefield/codex/upgrade-stream-client-v0.9.2-beta.1
chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1
2026-08-12 08:15:00 +00:00
john 74baac23a1 Merge branch 'main' into codex/upgrade-stream-client-v0.9.2-beta.1 2026-08-12 15:51:30 +08:00
玉澜 b31eaec78d docs: remove ZCode release fragment 2026-08-12 15:47:39 +08:00
xlb1130 34c5118e85 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 15:36:05 +08:00
玉澜 6e4ea0980f feat(skill): support ZCode skill root 2026-08-12 15:34:38 +08:00
github-actions[bot] 3ce0e001c1 Merge pull request #961 from yutongShe/feat/drive-file-comments
feat(drive): add file comment commands
2026-08-12 15:20:41 +08:00
xlb1130 077a5c3b30 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 14:57:37 +08:00
之桐 f3567fba71 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:54:18 +08:00
github-actions[bot] e7837cdc6b Merge pull request #964 from typefield/fix/upgrade-default-multi
fix(skill): avoid duplicate Agent skill roots
2026-08-12 14:50:57 +08:00
长真 88e2f8e9e2 chore(interface): address migration approval review feedback to #85200556 2026-08-12 14:40:52 +08:00
之桐 b131726497 docs: add drive file comment release fragment 2026-08-12 14:36:26 +08:00
之桐 86ec9733c0 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:35:22 +08:00
玉澜 8a60334978 Merge remote-tracking branch 'upstream/main' into fix/upgrade-default-multi 2026-08-12 14:24:52 +08:00
之桐 76a6980244 fix(drive): validate numeric file comment IDs 2026-08-12 14:24:50 +08:00
玉澜 fcbbc0bd9a fix(skill): require explicit nested layout migration 2026-08-12 14:21:47 +08:00
github-actions[bot] 31edcc3c5a Merge pull request #888 from DingTalk-Real-AI/codex/release-fragments
release: use isolated changelog fragments
2026-08-12 14:21:18 +08:00
wxianfeng bfd836064d feat: support optional flag rename governance to #85349380 2026-08-12 13:59:07 +08:00
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
玉澜 24ea2505a5 test(skill): cover upgrade migration branches 2026-08-12 13:44:14 +08:00
玉澜 566e94a31e fix(skill): make generic cleanup deterministic 2026-08-12 13:19:52 +08:00
玉澜 5c68e4d9cc fix(skill): avoid duplicate Agent skill roots 2026-08-12 12:32:53 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
长真 276ab52aed chore(interface): add im id flag migration pending approvals to #85200556 2026-08-12 12:14:10 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
Dennis 4324fa72f2 fix(drive): preserve copy schema properties 2026-08-12 11:56:10 +08:00
Dennis ef5462a4dc fix(drive): scan paginated file versions 2026-08-12 11:36:33 +08:00
之桐 bdf3048773 feat(drive): add file comment commands 2026-08-12 11:29:21 +08:00
Dennis e1da6ba356 fix(drive): preserve download output shorthand 2026-08-12 11:21:03 +08:00
Dennis ae309b5846 feat(drive): harden and expand shortcut workflows 2026-08-12 11:11:46 +08:00
玉澜 57e23d661d chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1 2026-08-12 10:57:37 +08:00
github-actions[bot] 9ef26055fa chore: update beta formula for v1.0.58-beta.4 [skip ci] 2026-08-12 02:45:42 +00:00
chichuan d1bd518043 Merge pull request #957 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.4
docs: seal v1.0.58-beta.4 changelog
2026-08-12 10:32:34 +08:00
chichuan bac4fded0d docs: seal v1.0.58-beta.4 changelog 2026-08-12 10:25:48 +08:00
github-actions[bot] 82bfddc1c2 Merge pull request #922 from typefield/feat/skill-mode-migration
feat(skill): default installs and upgrades to multi-skill layout
2026-08-12 09:04:30 +08:00
玉澜 8cf23ee7cb Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 08:47:01 +08:00
玉澜 5777ea36e9 fix(skill): roll back partial mono installs 2026-08-12 08:44:48 +08:00
github-actions[bot] 6eceebd701 Merge pull request #953 from Anonymity-0/feat/card-send-native-mentions
feat(chat): support mentions in native card creation
2026-08-12 02:41:26 +08:00
玉澜 4b898e9011 test(auth): remove PAT polling timing race 2026-08-12 02:41:14 +08:00
玉澜 cb14ae96b3 Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 02:07:42 +08:00
前津 aeb4b2dcaa fix(chat): reject conflicting card update responses 2026-08-12 02:01:13 +08:00
玉澜 09f9289deb fix(skill): match managed names literally 2026-08-12 01:56:17 +08:00
前津 bbb14c24dc Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 01:28:00 +08:00
github-actions[bot] 79f4be31d5 Merge pull request #956 from DingTalk-Real-AI/codex/fix-text-input-bounds
fix(localio): bound all text input paths
2026-08-11 17:16:44 +00:00
玉澜 e2a1be5e93 fix(skill): roll back partial setup transactions 2026-08-12 01:09:19 +08:00
Dennis 69911543c3 Merge remote-tracking branch 'origin/main' into codex/fix-text-input-bounds 2026-08-12 00:58:25 +08:00
john d4eba7fa96 Merge branch 'main' into feat/skill-mode-migration 2026-08-12 00:54:52 +08:00
前津 bbc2eb111c Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 00:54:37 +08:00
github-actions[bot] b58b8c51bf Merge pull request #955 from DingTalk-Real-AI/codex/fix-eval-dispatch-403
fix(ci): restore eval dispatch PR comments
2026-08-12 00:52:54 +08:00
Dennis a7678472ab test(localio): scope path replacement to unix 2026-08-12 00:40:36 +08:00
Dennis f413db06be fix(localio): reject special files before open 2026-08-12 00:32:56 +08:00
Dennis 3d67d83110 test(localio): isolate input boundary e2e 2026-08-12 00:28:43 +08:00
玉澜 9de722ab34 fix(skill): roll back failed installer transactions 2026-08-12 00:22:09 +08:00
Dennis df088573fb fix(localio): bound all text input paths 2026-08-11 23:58:11 +08:00
chichuan a0c64e5ef4 fix(ci): restore eval dispatch PR comments 2026-08-11 23:57:12 +08:00
前津 eebd6b2a1c fix(chat): accept card update acknowledgement 2026-08-11 23:44:53 +08:00
玉澜 181f030350 test(skill): normalize backup paths on Windows 2026-08-11 23:40:28 +08:00
john 6140e503ec Merge branch 'main' into feat/skill-mode-migration 2026-08-11 23:31:08 +08:00
玉澜 9539ae8e40 fix(skill): centralize managed skill metadata 2026-08-11 23:26:49 +08:00
github-actions[bot] 7a140e59c3 Merge pull request #946 from DingTalk-Real-AI/codex/minutes-shortcuts
feat(minutes): align and expand shortcut workflows
2026-08-11 23:20:21 +08:00
前津 b1bfe6002d Revert "docs(skill): route create-only cards to native command"
This reverts commit 8e8e3a3ce8.
2026-08-11 22:54:02 +08:00
Dennis 38832448d2 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 22:53:51 +08:00
前津 8e8e3a3ce8 docs(skill): route create-only cards to native command 2026-08-11 22:52:24 +08:00
Dennis 3d4e43f4fc fix(minutes): align search scope enums 2026-08-11 22:49:27 +08:00
github-actions[bot] 155ce984c9 Merge pull request #916 from gtezg30062/feat/pull_knowledge_base_dynamic_1
Feat/pull knowledge base dynamic 1
2026-08-11 14:21:30 +00:00
john 4b93a1cb28 Merge branch 'main' into feat/pull_knowledge_base_dynamic_1 2026-08-11 22:05:50 +08:00
github-actions[bot] 1d384b9189 Merge pull request #952 from DingTalk-Real-AI/feat/eval-devix-poll
feat(eval): 用可验证轮询中继替代受限网络直连
2026-08-11 21:51:30 +08:00
玉澜 9f4e748404 fix(skill): preserve installs during layout migration 2026-08-11 21:37:55 +08:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
玉澜 0d4bd28a08 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:46:15 +08:00
玉澜 9264323b29 revert(ci): keep existing pull request checkout policy 2026-08-11 20:45:32 +08:00
玉澜 1744880648 test(skill): cover managed marker failure 2026-08-11 20:34:00 +08:00
玉澜 246f4ebaf5 docs(skill): consolidate migration design into RFC 2026-08-11 20:24:07 +08:00
Dennis a3f5a83527 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 20:22:35 +08:00
玉澜 ec5f312fd6 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:19:30 +08:00
玉澜 3c81741e2e fix(skill): fail partial setup installs 2026-08-11 20:19:00 +08:00
Dennis 931af6af59 chore(pr): keep evidence out of merge tree 2026-08-11 19:51:57 +08:00
Dennis 44311d0160 docs(pr): publish minutes agent e2e evidence 2026-08-11 19:51:08 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
玉澜 293c085634 fix(skill): preserve same-prefix user skills 2026-08-11 19:47:30 +08:00
Dennis f81d09fb95 fix(localio): pin verified upload file across retries 2026-08-11 19:45:06 +08:00
前津 f8258576ef feat(chat): support mentions in native card creation 2026-08-11 19:42:06 +08:00
Dennis cd1ba34d96 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 19:30:15 +08:00
Dennis 2cc410db6c docs: remove shortcut analysis artifacts 2026-08-11 19:09:30 +08:00
玉澜 f57c002ae7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 19:06:57 +08:00
Dennis 06ec207d17 fix(minutes): harden end-to-end failure handling 2026-08-11 18:54:24 +08:00
玉澜 b17634ef7d fix(skill): fail incomplete bundled skill installs 2026-08-11 18:49:05 +08:00
玉澜 e0fd344a26 fix(skill): always refresh bundled skills 2026-08-11 18:13:44 +08:00
john c38e988b14 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:34:26 +08:00
Dennis 66468c703f fix(minutes): preserve upload recovery and schema compatibility 2026-08-11 17:06:22 +08:00
玉澜 773e76a1c6 Merge remote-tracking branch 'fork/feat/skill-mode-migration' into feat/skill-mode-migration 2026-08-11 17:01:44 +08:00
玉澜 f4e39a219b fix(skill): preserve state on partial setup 2026-08-11 17:01:32 +08:00
john c170a464e1 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:00:13 +08:00
Dennis 74ef426064 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 16:55:06 +08:00
玉澜 4665b42bbf fix(skill): preserve installer caches during refresh 2026-08-11 16:16:49 +08:00
john 16273de554 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 16:13:48 +08:00
john aabee99e3f Merge branch 'main' into feat/skill-mode-migration 2026-08-11 15:35:23 +08:00
玉澜 9e3a083c27 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 14:12:43 +08:00
玉澜 3a0d814276 docs(skill): remove confirmation bypass examples 2026-08-11 13:52:24 +08:00
玉澜 f3ddbb2db0 fix(upgrade): preserve skill cache during refresh 2026-08-11 13:17:07 +08:00
玉澜 fbdb5e8d4d Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 12:07:58 +08:00
玉澜 cc7e7bf0e0 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration
# Conflicts:
#	internal/app/skill_setup.go
#	internal/app/skill_setup_test.go
2026-08-11 12:06:59 +08:00
Dennis 461b9b773a feat(minutes): align and expand shortcut workflows 2026-08-11 11:52:57 +08:00
liangxiaoqin.lxq 4bd9f75231 cr修复1 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 8f8f64c391 cr修复,增加测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ae9caa06af cr修复 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ee0c3507a5 补充测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 72a9902254 wiki feed list命令 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 5f337e0ce5 wiki feed list命令:新增时间格式化/字段裁剪 2026-08-11 10:57:44 +08:00
玉澜 01a7b20026 fix(skill): keep setup confirmation and Windows tests safe 2026-08-11 10:46:40 +08:00
玉澜 62541947e7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 10:10:01 +08:00
玉澜 596da1343e fix(skill): sync installed multi-skill set safely 2026-08-11 00:10:59 +08:00
玉澜 12c7b6eb89 test(skill): cover mono cleanup failure on Windows 2026-08-10 22:50:52 +08:00
玉澜 bc3d92ccaf Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 22:36:07 +08:00
玉澜 61adc87987 fix(skill): fail safely during layout migration 2026-08-10 22:35:36 +08:00
玉澜 c515f7c1e5 test(ci): cover aggregate changed-code edges 2026-08-10 21:28:58 +08:00
玉澜 8eae408e28 fix(ci): pin synthetic merge to event SHA 2026-08-10 21:08:55 +08:00
玉澜 9f3df91584 fix(ci): pin merge checkout and cover Windows edges 2026-08-10 21:04:08 +08:00
玉澜 37cccdbc0e Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 20:51:58 +08:00
玉澜andCursor b6851e641e fix(skill): back up skill dirs before removal and satisfy coverage gate
Address the two P1 review findings and the coverage-gate CI failures:
- Every install/upgrade path that removes a skill dir (opposite-mode
  leftovers, stale dingtalk-* / dws-shared, and same-name refreshes) now
  moves the directory to ~/.dws/skill-backups/<stamp>/ first across
  install.sh, install-skills.sh, install.ps1, install.js, `dws skill
  setup`, and `dws upgrade`. A backup failure preserves the original
  directory and never removes it.
- Remove --yes from every copyable `dws skill setup` example and document
  what the command may remove; add regression tests that declining the
  confirmation performs no removal and that the confirmation previews
  every directory slated for backup+removal.
- Rename the skill-mode tests to the TestCrossPlatformCoverage* prefix so
  the platform coverage gate selects them, and add edge tests for the
  backup/prune/cleanup fallback branches, restoring changed-code coverage
  to 100%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:23:35 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
玉澜andCursor e02e4a666d Merge latest main into feat/skill-mode-migration
Upstream reorganized the multi-skill layout (#887: long-tail skills folded
into dingtalk-misc, dws-shared renamed to dingtalk-shared). Conflict
resolution keeps this branch's multi-by-default semantics (install.sh /
install.ps1 / skill setup default to multi; interactive prompts list multi
first) and adapts the cleanup paths to the rename: cleanup predicates now
recognize both dingtalk-shared (new bundle name, covered by the dingtalk-
prefix) and the legacy dws-shared so full installs and mode switches remove
pre-rename leftovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:03:33 +08:00
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +08:00
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:37 +08:00
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00
241 changed files with 36643 additions and 2842 deletions
+12
View File
@@ -0,0 +1,12 @@
---
category: Added
---
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
file|folder`, `--start`, and `--end` for client-side filtering by node type
and modification time on both the pan and workspace routes. Filtering runs
a bounded full scan of the target directory (2000-entry cap, reported via
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
+12
View File
@@ -0,0 +1,12 @@
---
category: Fixed
---
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
single-layer pan route now filters the returned page by name pattern; the
flag was previously accepted but silently ignored.
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
ranks the filtered entries (folders included when `--type folder` is set)
instead of unconditionally dropping folders, so the documented combination
returns the most recently modified folders rather than an empty list.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
@@ -0,0 +1,8 @@
---
category: Added
---
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
+9
View File
@@ -19,3 +19,12 @@
# Cache directory (optional, defaults to ~/.dws/cache)
# DWS_CACHE_DIR=
# Agent integration metadata (optional; ordinary non-plugin MCP requests only)
# DWS_AGENT_PRODUCT=example-agent
# DWS_AGENT_HOST=cloud
# DWS_AGENT_VER=0.1.5
# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
# The outer single quotes above are shell syntax and are not part of the value.
# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real
# tokens in committed files or use this metadata alone for authentication.
+4 -2
View File
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
+119 -16
View File
@@ -24,6 +24,7 @@ jobs:
pull-requests: read
outputs:
changelog_only: ${{ steps.classify.outputs.changelog_only }}
release_seal_only: ${{ steps.classify.outputs.release_seal_only }}
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
docs_only: ${{ steps.classify.outputs.docs_only }}
full_suite: ${{ steps.classify.outputs.full_suite }}
@@ -39,6 +40,7 @@ jobs:
with:
script: |
let changelogOnly = false;
let releaseSealOnly = false;
let changelogChanged = false;
let docsOnly = false;
let fullSuite = context.eventName === 'push';
@@ -151,6 +153,12 @@ jobs:
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
// Parameter aliases are reduced against the live command tree.
// Their reverse-dependency set is too large for one focused
// race job, so use the existing full-suite shards.
filename === 'internal/cli/param_concepts.json' ||
filename === 'internal/cli/param_concepts.schema.json' ||
filename === 'internal/cli/param_aliases_generated.go' ||
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
@@ -162,6 +170,43 @@ jobs:
filename === 'go.mod' ||
filename === 'go.sum'
);
const isExactReleaseSeal = (candidates) => {
const changelog = candidates.filter(
({ filename, status, previous_filename }) =>
filename === 'CHANGELOG.md' &&
status === 'modified' &&
!previous_filename
);
if (changelog.length !== 1 || candidates.length < 2) {
return false;
}
let version = '';
return candidates.every((file) => {
if (file.filename === 'CHANGELOG.md') {
return file.status === 'modified' && !file.previous_filename;
}
if (
file.status !== 'renamed' ||
typeof file.filename !== 'string' ||
typeof file.previous_filename !== 'string' ||
file.additions !== 0 ||
file.deletions !== 0
) {
return false;
}
const target = file.filename.match(
/^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/
);
if (!target || file.previous_filename !== `.changes/${target[2]}`) {
return false;
}
if (version && version !== target[1]) {
return false;
}
version = target[1];
return true;
});
};
const classifyFiles = (complete) => {
const paths = files.flatMap(({ filename, previous_filename }) =>
[filename, previous_filename].filter(
@@ -248,19 +293,26 @@ jobs:
);
}
changelogOnly =
const exactChangelogDiff =
files.length === 1 &&
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
releaseSealOnly = isExactReleaseSeal(files);
changelogOnly = exactChangelogDiff || releaseSealOnly;
changelogChanged = files.some(
({ filename, previous_filename }) =>
filename === 'CHANGELOG.md' ||
previous_filename === 'CHANGELOG.md'
);
classifyFiles(true);
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust = changelogOnly
? 'exact pull-request revision and synthetic merge policy'
? releaseSealOnly
? 'exact release-seal fragment archival and synthetic merge policy'
: 'exact CHANGELOG-only revision and synthetic merge policy'
: docsOnly
? 'documentation-only focused admission'
: fullSuite
@@ -295,7 +347,8 @@ jobs:
per_page: 100,
});
files = Array.isArray(comparison.files) ? comparison.files : [];
classifyFiles(files.length < 300);
const pushFilesComplete = files.length < 300;
classifyFiles(pushFilesComplete);
const linearFromValidatedTip =
comparison.status === 'ahead' &&
comparison.merge_base_commit?.sha === expectedBefore &&
@@ -307,8 +360,10 @@ jobs:
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
const exactReleaseSealDiff =
pushFilesComplete && isExactReleaseSeal(files);
if (linearFromValidatedTip && exactChangelogDiff) {
if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) {
const requiredContexts = [
'Lint',
'Test',
@@ -359,9 +414,15 @@ jobs:
if (missing.length === 0 && nonSuccess.length === 0) {
changelogOnly = true;
releaseSealOnly = exactReleaseSealDiff;
changelogChanged = true;
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust =
`exact CHANGELOG-only successor of validated ${expectedBefore}`;
releaseSealOnly
? `exact release-seal successor of validated ${expectedBefore}`
: `exact CHANGELOG-only successor of validated ${expectedBefore}`;
} else {
fastPathTrust =
'predecessor Code Admission is not fully successful; ' +
@@ -376,6 +437,7 @@ jobs:
}
core.setOutput('changelog_only', String(changelogOnly));
core.setOutput('release_seal_only', String(releaseSealOnly));
core.setOutput('changelog_changed', String(changelogChanged));
core.setOutput('docs_only', String(docsOnly));
core.setOutput('full_suite', String(fullSuite));
@@ -387,7 +449,8 @@ jobs:
await core.summary
.addHeading('Code Admission scope')
.addRaw(`- Event: \`${context.eventName}\`\n`)
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
.addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`)
.addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`)
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
@@ -402,7 +465,14 @@ jobs:
- name: Record CHANGELOG-only fast path
if: steps.classify.outputs.changelog_only == 'true'
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
env:
RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }}
run: |
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY"
else
echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Record documentation-only fast path
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
@@ -495,7 +565,8 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
# app runs several independently bounded processes; cli/smoke need headroom
# beyond go test -timeout for setup + assembly.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -531,10 +602,19 @@ jobs:
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
if [ "$TEST_SHARD" = "app" ]; then
# A single long-lived app test process retains every constructed
# command tree in framework registries. Isolate Schema assembly and
# bounded name ranges so each process releases that state on exit.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
exit 0
fi
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
if [ "$TEST_SHARD" = "cli" ] ||
[ "$TEST_SHARD" = "smoke" ]; then
timeout_budget=15m
fi
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
@@ -1256,6 +1336,7 @@ jobs:
env:
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
@@ -1282,31 +1363,53 @@ jobs:
fi
mode=--content-only
if [ "$CHANGELOG_ONLY" = true ]; then
if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then
mode=--fast-path
fi
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
- name: Validate release fragment lifecycle
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main metadata-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
set -eu
test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || {
echo "checked-out push revision does not match event after SHA" >&2
exit 1
}
mode=--fast-path
if [ "$RELEASE_SEAL_ONLY" = true ]; then
mode=--content-only
fi
./scripts/policy/check-changelog-pr.sh \
--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
./scripts/policy/check-release-fragments.sh \
"$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
fi
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
env:
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
else
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
fi
- name: Validate scoped policy
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
+15 -29
View File
@@ -34,8 +34,8 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
@@ -69,12 +69,10 @@ jobs:
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
@@ -103,13 +101,9 @@ jobs:
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
jq -n --arg body "$placeholder_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
@@ -277,13 +271,9 @@ jobs:
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
jq -n --arg body "$body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
@@ -299,12 +289,8 @@ jobs:
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
jq -n --arg body "$failure_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+35 -1
View File
@@ -2645,6 +2645,40 @@ jobs:
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
# The sealed candidate tag is intentionally visible while its GitHub
# authority is checked above. Compatibility must instead discover the
# previous delivered stable tag, so hide only this verified candidate
# from this isolated runner's local tag namespace.
- name: Prepare delivered-stable compatibility ref view
if: ${{ matrix.check == 'compatibility' }}
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
run: |
set -eu
test -n "$RELEASE_VERSION"
test -n "$RELEASE_COMMIT"
test -n "$RELEASE_TAG_OBJECT"
test -n "$PREVIOUS_STABLE"
test -n "$PREVIOUS_STABLE_COMMIT"
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
exit 2
fi
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -2793,7 +2827,7 @@ jobs:
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
+103
View File
@@ -6,6 +6,109 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58] - 2026-08-13
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
### Changed
- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes,
approval-event, Drive-comment, document export, and CSV workflow support,
including safer validation, explicit confirmation for writes, and
machine-readable completion receipts.
- **More capable Chat operations** — adds robot image/file messages, toolbar
management, streaming-card mentions, automatic pagination controls, and
clearer post-send ID, Markdown-image, paging, and result-shape guidance.
- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and
Minutes commands, aligns bundled skills, improves schema/result envelopes,
and hardens parameter, pagination, runtime-token, and write-result
verification so ambiguous or incomplete operations fail closed.
- **Multi-skill install and upgrade** — makes the multi-skill layout the
default for fresh installs and upgrades while preserving an explicit legacy
mono option.
- **Safer release delivery** — strengthens release-equivalent compatibility,
sealing, package verification, and evaluation-dispatch checks for more
reliable cross-platform releases.
## [1.0.58-beta.6] - 2026-08-13
### Fixed
- **npm package verification for multi-skill installs** (#991) — aligns the
release verifier with the installer’s concrete Agent skill-root selection,
preventing valid multi-skill package layouts from failing release delivery.
### Changed
- **Release-seal CI classification** (#987) — recognizes the reviewed
CHANGELOG-and-fragment archival shape while retaining release-contract and
lifecycle validation, reducing unrelated CI work for release-seal PRs.
## [1.0.58-beta.5] - 2026-08-13
### Added
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
### Changed
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
## [1.0.58-beta.4] - 2026-08-12
### Added
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
and `dws upgrade` now use the multi-skill layout by default. Existing mono
installations migrate during upgrade; mono remains an explicit legacy option.
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
`create_and_send_card`, matching the existing shortcut behavior without
changing single-chat card creation.
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
upload, download, export, recording, analysis, sharing, and recovery flows;
every write command keeps an explicit confirmation requirement.
- **Chat command discovery** — 30 existing typed Chat commands are now
available in the runtime Schema and Agent catalog, with sensitive writes
carrying their required confirmation metadata.
### Changed
- **Chat read results** — typed commands and shortcuts now expose a consistent
top-level `messages` list with stable `messageId` and `text` fields while
retaining existing response envelopes and fields.
- **Wiki feed results** — Wiki feed list output now formats time fields and
trims excess fields. Its `--limit` default is 10 and maximum is 20.
- **Developer command results** — the `dev` and selected `devapp` commands now
use the unified result envelope for consistent success, pending, partial,
and failure reporting.
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
relay instead of direct access from the hosted runner, binding the workflow,
comment, PR head, parameters, and result provenance.
### Fixed
- **Streaming-card update acknowledgement** — accepts the pre-production
`success: true` response from `update_streaming_card` as affirmative write
evidence while preserving explicit negative, conflicting, and bizId-drift
failures, so Agents do not repeat an update that the service already applied.
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
enforce the same byte limit; file reads validate the opened descriptor and
cannot exceed the limit after a path replacement or file growth.
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
the least required pull-request write permission and actionable GitHub 403
diagnostics.
## [1.0.58-beta.3] - 2026-08-11
### Added
+4 -1
View File
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
## Submission Flow
+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.58-beta.3"
version "1.0.58-beta.6"
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.58-beta.3/dws-darwin-arm64.tar.gz"
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57"
version "1.0.58"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
end
def install
+30 -19
View File
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
</details>
@@ -208,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to mono)
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -413,22 +415,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Preview the exact directories that mono setup would back up and replace
dws skill setup --mode mono --target all --dry-run
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Point at a local source tree (e.g. a fork or work-in-progress)
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
@@ -726,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
</details>
+30 -19
View File
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
</details>
@@ -205,7 +207,7 @@ bash verify-all-channels.sh
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 mono)
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -407,22 +409,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 指定本地源目录(比如 fork 或正在改的版本)
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
@@ -715,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
</details>
+637 -17
View File
@@ -3,6 +3,7 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const childProcess = require("child_process");
@@ -16,6 +17,7 @@ const AGENT_DIRS = [
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -45,6 +47,58 @@ function ensureCleanDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// backupStamp returns the UTC timestamp used for backup directory names,
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
function backupStamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
// installs cannot confirm, so removals must stay reversible). Missing paths
// are a no-op success. On any backup failure the directory is left in place
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return true;
}
const rel = path.relative(homeDir, dir);
const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("-")
: path.basename(dir);
const stamp = backupStamp();
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
let targetRoot = path.join(backupRoot, stamp);
let target = path.join(targetRoot, name);
for (let i = 1; fs.existsSync(target); i++) {
if (i > 1000) {
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
return false;
}
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
target = path.join(targetRoot, name);
}
try {
fs.mkdirSync(targetRoot, { recursive: true });
renameFn(dir, target);
} catch (err) {
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
return false;
}
if (backups) {
backups.push({ original: dir, backup: target });
}
console.log(` × 已备份并移除 ${dir} → ${target}`);
return true;
}
function findBinary(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
@@ -117,47 +171,587 @@ function copyChildren(srcDir, destDir) {
}
}
// publishCacheAtomically prepares a complete sibling tree before replacing a
// cache. If copying or publishing fails, the previous cache stays available.
// copyFn is injectable so the failure contract can be tested without relying
// on platform-specific permission behavior.
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
const cacheParent = path.dirname(cacheDir);
const cacheName = path.basename(cacheDir);
fs.mkdirSync(cacheParent, { recursive: true });
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
let rollbackDir = "";
let published = false;
try {
copyFn(sourceDir, stagedDir);
if (fs.existsSync(cacheDir)) {
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.renameSync(cacheDir, rollbackDir);
}
try {
fs.renameSync(stagedDir, cacheDir);
published = true;
} catch (publishErr) {
if (rollbackDir) {
try {
fs.renameSync(rollbackDir, cacheDir);
rollbackDir = "";
} catch (restoreErr) {
throw new Error(
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
);
}
}
throw publishErr;
}
if (rollbackDir) {
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn(
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
);
}
rollbackDir = "";
}
} finally {
if (!published) {
fs.rmSync(stagedDir, { recursive: true, force: true });
}
}
}
function installSkillsToHomes(skillRoot) {
const homeDir = os.homedir();
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
installed += 1;
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (installed === 0) {
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
}
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
// Frozen exact names shipped before centralized ownership metadata. Retired
// names stay here so old installs can be migrated without treating every
// dingtalk-* directory as DWS-owned.
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
]);
function skillStateDir(homeDir) {
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
}
function readManagedSkillNames(homeDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
} catch (_) {
return new Set();
}
}
function isManagedMultiSkillDir(dir, managedNames) {
const name = path.basename(dir);
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
const backups = [];
try {
for (const victim of victims) {
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
} catch (err) {
const restoreErrors = [];
for (let i = backups.length - 1; i >= 0; i -= 1) {
try {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
} catch (restoreErr) {
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(full, rel);
} else {
files.push({ rel, full });
}
}
};
visit(dir, "");
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file.rel, "utf8");
hash.update(Buffer.from([0]));
hash.update(fs.readFileSync(file.full));
hash.update(Buffer.from([0]));
}
return `sha256:${hash.digest("hex")}`;
}
// Publish a complete multi-skill set as one transaction. The entire new set
// is staged before any Agent-visible directory moves. If a later backup or
// publish fails, every partial publication is removed and all old directories
// are restored from their exact backup paths.
function publishManagedMultiSkillSetAtomically(
homeDir,
multiRoot,
baseDir,
skills,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
for (const name of skills) {
const stagedDir = path.join(stageRoot, name);
copyFn(path.join(multiRoot, name), stagedDir);
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
renameFn(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
// Publish mono plus every mutually-exclusive managed multi victim as one
// transaction. The complete dws/ tree is staged before any live directory is
// moved; a later backup or publish failure restores the exact previous set.
function publishManagedMonoSkillSetAtomically(
homeDir,
monoRoot,
baseDir,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
const stagedDir = path.join(stageRoot, "dws");
const destDir = path.join(baseDir, "dws");
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
copyFn(monoRoot, stagedDir);
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
published.push(destDir);
renameFn(stagedDir, destDir);
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
function writeSkillsState(homeDir, multiRoot, skills) {
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
const managedSkills = [...skills].sort().map((name) => ({
name,
version,
source: "npm-postinstall",
digest: skillDirectoryDigest(path.join(multiRoot, name)),
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
}));
const state = {
version,
official_skills: [...skills].sort(),
updated_skills: [...skills].sort(),
managed_skills: managedSkills,
updated_at: new Date().toISOString(),
};
const stateDir = skillStateDir(homeDir);
fs.mkdirSync(stateDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
const stagedFile = path.join(stage, "skills-state.json");
const statePath = path.join(stateDir, "skills-state.json");
const rollbackPath = path.join(stage, "skills-state.previous.json");
let movedPrevious = false;
let preserveRecovery = false;
try {
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
if (fs.existsSync(statePath)) {
fs.renameSync(statePath, rollbackPath);
movedPrevious = true;
}
try {
fs.renameSync(stagedFile, statePath);
} catch (err) {
if (movedPrevious && !fs.existsSync(statePath)) {
try {
fs.renameSync(rollbackPath, statePath);
movedPrevious = false;
} catch (restoreErr) {
preserveRecovery = true;
throw new Error(
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
);
}
}
throw err;
}
} finally {
if (!preserveRecovery) {
fs.rmSync(stage, { recursive: true, force: true });
}
}
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
// present in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
// Mutual exclusion: include the mono leftover and stale managed skills in
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) {
victims.push(path.join(baseDir, name));
}
try {
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
}
writeSkillsState(homeDir, multiRoot, skills);
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
publishCacheAtomically(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
publishCacheAtomically(multiSource, multiCache);
}
}
@@ -191,8 +785,34 @@ function main() {
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
}
main();
if (require.main === module) {
main();
}
module.exports = {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
};
+14 -8
View File
@@ -48,8 +48,12 @@ It then runs:
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
+5 -5
View File
@@ -1,6 +1,6 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
<title>Drive Shortcut 能力全景|业务评审版</title>
<style>
:root {
color-scheme: light;
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
a { color: inherit; text-decoration: none; }
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
.nav .wrap { display: flex; overflow-x: auto; }
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
.nav a:hover { color: var(--forest); background: #eaf3ee; }
main { padding: 38px 0 74px; }
section { margin-top: 50px; scroll-margin-top: 72px; }
section:first-child { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
h3 { margin: 0 0 7px; font-size: 18px; }
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
.callout strong { color: #13513d; }
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
.callout.warn strong { color: #784b00; }
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.score { padding: 19px; }
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
.domain-grid { grid-template-columns: repeat(4, 1fr); }
.domain-card { position: relative; padding: 21px; overflow: hidden; }
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
.domain-card small { color: var(--green); font-weight: 800; }
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
.compare-column:last-child { border-right: 0; }
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
.compare-column.covered { border-top: 5px solid var(--green); }
.compare-column.partial { border-top: 5px solid #c78b22; }
.compare-column.gap { border-top: 5px solid var(--red); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbf8; }
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
.v-covered, .badge.read { color: #116045; background: var(--mint); }
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
.truth-step strong, .truth-step span { display: block; }
.truth-step span { color: var(--muted); font-size: 12px; }
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-row:hover { background: #f8fbf8; }
.command code { font-size: 12px; }
.row-main p { margin: 0; font-size: 13px; }
.row-main small { color: var(--muted); }
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.hidden-row { display: none; }
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
.evidence-card { padding: 19px; }
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
.event { position: relative; padding: 0 0 17px 22px; }
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
.event b { display: block; }
.event span { color: var(--muted); font-size: 12px; }
.review-grid { grid-template-columns: repeat(3, 1fr); }
.review-card { position: relative; padding: 20px; }
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
</style>
</head>
<body>
<header class="hero">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">Business Review · Drive</p>
<h1>Drive Shortcut<br>能力全景</h1>
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
<div class="meta-row">
<span class="meta-pill good">真实账号 E2E 已执行</span>
<span class="meta-pill">28 个公开入口</span>
<span class="meta-pill">统一 Result / Pagination</span>
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
</div>
</div>
<div class="hero-stats" aria-label="关键统计">
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
</div>
</div>
</header>
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
<main class="wrap">
<section id="overview">
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
<div class="score-grid">
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
</div>
</section>
<section>
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
<div class="domain-grid">
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
</div>
</section>
<section id="compare">
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
<div class="compare">
<div class="compare-top">
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
</div>
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
</tbody></table></div>
</div>
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
</section>
<section id="ahead">
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
<div class="domain-grid">
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
</div>
</section>
<section id="truth">
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
<div class="truth-grid">
<article class="truth-card"><h3>四层成功证据</h3>
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
</article>
<article class="truth-card"><h3>明确失败的情况</h3>
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
</article>
</div>
</section>
<section id="catalog">
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
<div class="catalog">
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
</div>
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
</section>
<section id="e2e">
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
<div class="evidence-grid">
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
</div>
<div class="timeline">
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
</div>
</section>
<section id="review">
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
<div class="review-grid">
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
</div>
</section>
</main>
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
<script>
const q = document.querySelector('#q');
const domain = document.querySelector('#domain');
const risk = document.querySelector('#risk');
const rows = [...document.querySelectorAll('[data-tool]')];
const count = document.querySelector('#result-count');
function filterTools() {
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
let visible = 0;
rows.forEach((row) => {
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
row.classList.toggle('hidden-row', !show);
if (show) visible += 1;
});
count.textContent = `显示 ${visible} / ${rows.length}`;
}
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
# Drive Shortcut 对齐与超越 Lark CLI
## 目标与判定口径
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
## Lark 38 项映射
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|---|---|---|
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
| `+add-reply` | `doc +comment-reply` | 对齐。 |
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
## DWS 超出 Lark Drive 的可挖掘能力
- `+list`:严格目录分页,而不是把缺字段当空目录。
- `+recent`:最近访问/编辑与创建人筛选。
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
- `+rename`:写后读回验证。
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
## 端到端门禁
每个公开 Drive shortcut 必须至少覆盖:
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
- 写入的 ID/终态证据与读回不一致;
- 下载的本地路径边界、no-clobber、真实字节数;
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
+43
View File
@@ -7,6 +7,8 @@
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 |
| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,6 +16,47 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Version and Extended Context / Agent 版本与扩展上下文
`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary,
non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or
the separate `X-Cli-Version` that identifies the DWS CLI version, and they are
not forwarded to A2A, OAuth, Discovery, or third-party plugin requests.
`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The
recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps
the object extensible and does not enforce a key allowlist. For example, using
fictional, redacted values:
```bash
DWS_AGENT_VER=0.1.5
DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
```
The shell's outer single quotes group the JSON and are not part of the
environment-variable value. The CLI trims surrounding ASCII spaces/tabs,
omits either Header when its value is empty, and compacts EXT to a single-line
JSON object. A representative current payload is about 657 bytes, well below
the 8 KiB limit; integrations must still enforce the limit because values can
grow. EXT may contain sensitive device or runtime signals: the CLI masks it in
configuration and logs, and removes it on a cross-host redirect.
Both values are declared by the caller and are therefore forgeable. They can
support compatibility checks, diagnostics, and observability, but they are not
credentials or attestations and must never be sufficient on their own to
authenticate a caller or authorize access.
`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会
改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`;
二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象
请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制
扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载
约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的
设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。
这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性,
但不是凭据或可信证明,不能单独用于身份认证或访问授权。
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
+13 -2
View File
@@ -17,7 +17,8 @@
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
+219
View File
@@ -0,0 +1,219 @@
# RFC:DWS 预制 Skill 安装、升级与模式迁移
| 字段 | 内容 |
|---|---|
| 状态 | Accepted / as implemented |
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
## 1. 背景
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
## 2. 目标与非目标
### 2.1 目标
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
- 安装预览、确认和实际执行使用同一份计划。
### 2.2 非目标
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
- 不持久化用户对预制 Skill 的本地删除或排除意图。
- 不提供跨所有 Agent 目标的事务式回滚。
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
## 3. 业内调研
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
| 观察 | 对 DWS 的启示 |
|---|---|
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
## 4. 布局合同
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
不可恢复的删除。
## 5. 官方集合与升级策略
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
- 本地删除的预制 Skill 会在下一次升级恢复;
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
- 新版本新增的官方 Skill 会自动安装;
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
- `dingtalk-shared` 始终随官方集合安装。
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
provenance,供安全清理、诊断与后续迁移使用。
## 6. 目录所有权
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
`~/.dws/skills-state.json` 中写入:
```json
{
"version": "v0.2.14",
"official_skills": ["dingtalk-aitable"],
"updated_skills": ["dingtalk-aitable"],
"managed_skills": [
{
"name": "dingtalk-aitable",
"version": "v0.2.14",
"source": "dws-upgrade",
"digest": "sha256:<64 个十六进制字符>",
"digest_scope": "skill-directory-v1"
}
],
"updated_at": "2026-08-11T12:34:56Z"
}
```
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
覆盖恢复。
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
1. Skill 名称存在于统一状态的 `managed_skills` 中;
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
### 6.1 对 Agent 的影响
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
不会成为提示词上下文或影响 Agent 行为。
## 7. Setup:Plan → Confirm → Execute
`dws skill setup` 分为三个阶段:
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
3. **Execute**:确认后严格执行计划中的备份和安装。
安全要求:
- 非交互环境未传 `--yes` 时拒绝执行;
- 用户拒绝确认时必须零文件写入;
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
- 同一目标先完成所有必要备份,再复制新集合;
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
## 10. 缓存与制品
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
setup 在未显式指定 `--source` 时的本地回退缓存。
缓存刷新必须采用同级 staging + publish:
1. 在 staging 中完整复制并验证新树;
2. 发布前保留旧缓存;
3. 通过 rename 发布新缓存;
4. 复制或发布失败时保留或恢复旧缓存;
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
## 11. 安装入口一致性
以下入口都遵守本 RFC:
| 入口 | 默认模式 | 失败合同 |
|---|---|---|
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
## 12. 验收与回归门禁
合入和后续修改至少覆盖:
- mono → multi、multi → mono 互斥切换;
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
- 统一状态中登记的过期官方 Skill 被备份并移除;
- 备份、复制、统一状态写入、缓存 publish 故障注入;
- 非交互确认拒绝与显式 `--yes`;
- 部分失败返回非零且不写错误状态快照;
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
-188
View File
@@ -1,188 +0,0 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+501 -42
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"results": [
{
"suite": "semantic",
@@ -2162,7 +2162,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -2650,8 +2650,8 @@
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。",
"availability": "available"
},
{
@@ -2660,8 +2660,8 @@
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。",
"availability": "available"
},
{
@@ -2670,8 +2670,8 @@
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。",
"availability": "available"
},
{
@@ -2841,7 +2841,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"semantic_delta": "按名称或关键词检索模板并返回可消歧候选和 templateId。",
"availability": "available"
},
{
@@ -2860,8 +2860,8 @@
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"disposition": "semantic_adapter",
"semantic_delta": "版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。",
"availability": "available"
},
{
@@ -2870,8 +2870,8 @@
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"disposition": "primary_smart",
"semantic_delta": "版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。",
"availability": "available"
},
{
@@ -2880,58 +2880,289 @@
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"disposition": "semantic_adapter",
"semantic_delta": "版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+cover",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-folder",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建普通钉盘文件夹后要求 fileId,并读回名称验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-shortcut",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "明确 shortcut 与 copy 语义差异,创建后读取新节点验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+find-file",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+info",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+publish-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+publish-unset",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后关闭互联网公开,并读回状态验证外链已失效。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recent",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+recycle-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recycle-restore",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "重命名后读取真实节点元数据验证最终名称。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+search-docs",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出当前用户收藏并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义取消收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+stats",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按正整数版本号精确匹配元数据;零命中显式失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-history",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出普通文件版本,区分合法空历史与响应契约错误。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。",
"availability": "available"
},
{
"suite": "read",
@@ -3004,46 +3235,274 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+action-items",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+apply-permission",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+detail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+export-pack",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+list-all",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-mine",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-shared",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+mindmap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+prepare-asr",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-pause",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-resume",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-start",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+record-stop",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-wrap-up",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+replace-batch",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-insights",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-replace",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+summary",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+transcript",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+unshare",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload-and-analyze",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
"availability": "available"
},
{
"suite": "read",
+3 -13
View File
@@ -2,7 +2,8 @@
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 两棵内容树
@@ -87,18 +88,7 @@ skills/mono/
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
## 6. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
+2 -4
View File
@@ -3,6 +3,8 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 质检矩阵
@@ -70,7 +72,3 @@ paired_files:
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+2 -1
View File
@@ -14,8 +14,9 @@ require (
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.5
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
+4 -2
View File
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+1 -1
View File
@@ -165,7 +165,7 @@ func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
func TestCrossPlatformCoverageRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, invalidValue)
+248
View File
@@ -0,0 +1,248 @@
// 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 app
import (
"bytes"
"context"
"encoding/json"
"os"
"regexp"
"strings"
"unicode"
"unicode/utf8"
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/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentVersion = "DWS_AGENT_VER"
envDWSAgentExt = "DWS_AGENT_EXT"
maxAgentVersionBytes = 64
maxAgentExtensionBytes = 8 * 1024
)
var agentVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
type agentMetadataSnapshot struct {
version string
ext string
versionErr error
extErr error
}
type agentMetadataSnapshotContextKey struct{}
func (snapshot agentMetadataSnapshot) validationError() error {
if snapshot.versionErr != nil {
return snapshot.versionErr
}
return snapshot.extErr
}
func contextWithAgentMetadataSnapshot(ctx context.Context, snapshot agentMetadataSnapshot) context.Context {
return context.WithValue(ctx, agentMetadataSnapshotContextKey{}, snapshot)
}
func agentMetadataSnapshotFromContext(ctx context.Context) (agentMetadataSnapshot, bool) {
if ctx == nil {
return agentMetadataSnapshot{}, false
}
snapshot, ok := ctx.Value(agentMetadataSnapshotContextKey{}).(agentMetadataSnapshot)
return snapshot, ok
}
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentVersion,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 版本;仅作为 x-dws-agent-ver 透传到非插件 MCP 请求",
Example: "1.2.3-beta.1+build.7",
})
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentExt,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 扩展上下文 JSON;仅作为 x-dws-agent-ext 透传到非插件 MCP 请求",
Example: `{"umt":"<token>","miniwua":"<token>","ua":"agent/1.0"}`,
Sensitive: true,
})
}
// parseAgentVersion normalizes and validates the caller-declared Agent
// version. Only surrounding ASCII spaces and tabs are trimmed. An unset or
// ASCII-whitespace-only value means "do not emit".
func parseAgentVersion(raw string) (string, error) {
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentVersionBytes || !agentVersionPattern.MatchString(value) {
return "", invalidAgentVersionError()
}
return value, nil
}
// parseAgentExt validates one generic JSON object and returns its compact
// one-line representation. Raw control characters other than horizontal tab
// are rejected before JSON parsing; escaped JSON control characters remain
// valid because they are safe on the HTTP header wire.
func parseAgentExt(raw string) (string, error) {
if len(raw) > maxAgentExtensionBytes || !utf8.ValidString(raw) {
return "", invalidAgentExtError()
}
for _, r := range raw {
if unicode.IsControl(r) && r != '\t' {
return "", invalidAgentExtError()
}
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(value)); err != nil {
return "", invalidAgentExtError()
}
compactBytes := compact.Bytes()
if len(compactBytes) > maxAgentExtensionBytes || len(compactBytes) < 2 || compactBytes[0] != '{' {
return "", invalidAgentExtError()
}
return compact.String(), nil
}
func invalidAgentVersionError() error {
return apperrors.NewValidation(
"DWS_AGENT_VER must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9._+-]*$",
apperrors.WithReason("invalid_agent_version"),
)
}
func invalidAgentExtError() error {
return apperrors.NewValidation(
"DWS_AGENT_EXT must be a UTF-8 JSON object of at most 8192 bytes without raw control characters",
apperrors.WithReason("invalid_agent_ext"),
)
}
// readAgentMetadataSnapshot reads both environment variables from one
// os.Environ snapshot, then parses them once. Normal CLI execution retains the
// validated result through the invocation so hooks and transport observe the
// same pair even in an embedding process that mutates its environment.
func readAgentMetadataSnapshot() agentMetadataSnapshot {
var rawVersion, rawExt string
for _, entry := range os.Environ() {
key, value, _ := strings.Cut(entry, "=")
switch key {
case envDWSAgentVersion:
rawVersion = value
case envDWSAgentExt:
rawExt = value
}
}
version, versionErr := parseAgentVersion(rawVersion)
ext, extErr := parseAgentExt(rawExt)
return agentMetadataSnapshot{
version: version,
ext: ext,
versionErr: versionErr,
extErr: extErr,
}
}
// removeAgentMetadataHeaders removes every case variant so edition or
// credential hooks cannot smuggle MCP-only metadata into shared transports.
func removeAgentMetadataHeaders(headers map[string]string) {
for key := range headers {
if strings.EqualFold(key, transport.HeaderAgentVersion) ||
strings.EqualFold(key, transport.HeaderAgentExt) {
delete(headers, key)
}
}
}
// applyAgentMetadataHeaders applies validated environment values as the final
// authority for non-plugin MCP requests. Invalid values are omitted on
// library paths that bypass root validation; normal CLI execution rejects
// them before hooks or network access.
func applyAgentMetadataHeaders(headers map[string]string) map[string]string {
return applyAgentMetadataSnapshot(headers, readAgentMetadataSnapshot())
}
func applyAgentMetadataSnapshot(headers map[string]string, snapshot agentMetadataSnapshot) map[string]string {
removeAgentMetadataHeaders(headers)
if (snapshot.versionErr != nil || snapshot.version == "") && (snapshot.extErr != nil || snapshot.ext == "") {
return headers
}
if headers == nil {
headers = make(map[string]string)
}
if snapshot.versionErr == nil && snapshot.version != "" {
headers[transport.HeaderAgentVersion] = snapshot.version
}
if snapshot.extErr == nil && snapshot.ext != "" {
headers[transport.HeaderAgentExt] = snapshot.ext
}
return headers
}
// resolveMCPRequestHeaders adds Agent version and extension metadata only to
// the built-in DingTalk MCP request path. Shared identity consumers (notably
// A2A) continue to use resolveIdentityHeaders and never receive these fields.
func resolveMCPRequestHeaders() map[string]string {
return resolveMCPRequestHeadersWithSnapshot(readAgentMetadataSnapshot())
}
func resolveMCPRequestHeadersWithSnapshot(snapshot agentMetadataSnapshot) map[string]string {
return applyAgentMetadataSnapshot(resolveIdentityHeaders(), snapshot)
}
// resolveMCPRequestHeadersForInvocation resolves one immutable Header snapshot
// for an invocation. The helper-only mcp-meta server performs endpoint
// discovery rather than an ordinary MCP product call, so caller-declared
// Agent metadata must not cross that boundary.
func resolveMCPRequestHeadersForInvocation(invocation executor.Invocation, snapshots ...agentMetadataSnapshot) map[string]string {
headers := resolveIdentityHeaders()
if strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), mcpMetaServerID) {
return headers
}
snapshot := readAgentMetadataSnapshot()
if len(snapshots) > 0 {
snapshot = snapshots[0]
}
return applyAgentMetadataSnapshot(headers, snapshot)
}
// pluginRequestHeaders returns a private, sanitized copy of plugin-owned
// Headers. Third-party plugins never receive DWS-owned Agent metadata, even if
// their manifest tries to declare the reserved Header names itself.
func pluginRequestHeaders(pluginAuth *PluginAuth) map[string]string {
if pluginAuth == nil || len(pluginAuth.ExtraHeaders) == 0 {
return nil
}
headers := make(map[string]string, len(pluginAuth.ExtraHeaders))
for key, value := range pluginAuth.ExtraHeaders {
headers[key] = value
}
removeAgentMetadataHeaders(headers)
if len(headers) == 0 {
return nil
}
return headers
}
+743
View File
@@ -0,0 +1,743 @@
// 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 app
import (
"context"
"encoding/json"
"errors"
"io"
"maps"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
outputpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"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/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageParseAgentVersion(t *testing.T) {
var nilContext context.Context
if _, ok := agentMetadataSnapshotFromContext(nilContext); ok {
t.Fatal("nil context unexpectedly contained Agent metadata")
}
wantSnapshot := agentMetadataSnapshot{version: "context-version", ext: "{}"}
if got, ok := agentMetadataSnapshotFromContext(contextWithAgentMetadataSnapshot(context.Background(), wantSnapshot)); !ok || got != wantSnapshot {
t.Fatalf("context Agent metadata = %#v, %v; want %#v", got, ok, wantSnapshot)
}
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "semantic version", raw: "1.2.3", want: "1.2.3"},
{name: "pre-release and build", raw: " v1.2.3-rc.1+build_7 ", want: "v1.2.3-rc.1+build_7"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentVersionBytes), want: strings.Repeat("a", maxAgentVersionBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentVersion(tc.raw)
if err != nil {
t.Fatalf("parseAgentVersion() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentVersion() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "leading punctuation", raw: "-1.2.3"},
{name: "internal space", raw: "1.2 3"},
{name: "slash", raw: "1.2/3"},
{name: "line feed", raw: "1.2.3\n"},
{name: "carriage return", raw: "1.2.3\r"},
{name: "NUL", raw: "1.2\x003"},
{name: "Unicode", raw: "版本1"},
{name: "too long", raw: strings.Repeat("a", maxAgentVersionBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentVersion(tc.raw)
if err == nil || got != "" {
t.Fatalf("parseAgentVersion(%q) = %q, %v; want validation error", tc.raw, got, err)
}
assertAgentMetadataValidationError(t, err, "invalid_agent_version", tc.raw)
})
}
}
func TestCrossPlatformCoverageParseAgentExt(t *testing.T) {
boundary := `{"x":"` + strings.Repeat("a", maxAgentExtensionBytes-8) + `"}`
if len(boundary) != maxAgentExtensionBytes {
t.Fatalf("invalid boundary fixture size: %d", len(boundary))
}
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "empty object", raw: "{}", want: "{}"},
{name: "compact generic object", raw: " \t{ \"umt\": \"masked\",\t \"nested\": { \"ok\": true }, \"unknown\": [1, 2] }\t ", want: `{"umt":"masked","nested":{"ok":true},"unknown":[1,2]}`},
{name: "Unicode value", raw: `{"ua":"千问办公/1.0"}`, want: `{"ua":"千问办公/1.0"}`},
{name: "escaped control remains safe", raw: `{"ua":"line\nnext"}`, want: `{"ua":"line\nnext"}`},
{name: "maximum length", raw: boundary, want: boundary},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentExt(tc.raw)
if err != nil {
t.Fatalf("parseAgentExt() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentExt() = %q, want %q", got, tc.want)
}
})
}
invalidUTF8 := string([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'})
invalid := []struct {
name string
raw string
}{
{name: "too long raw input", raw: strings.Repeat(" ", maxAgentExtensionBytes+1)},
{name: "invalid UTF-8", raw: invalidUTF8},
{name: "array", raw: `[]`},
{name: "string", raw: `"value"`},
{name: "number", raw: `1`},
{name: "boolean", raw: `true`},
{name: "null", raw: `null`},
{name: "malformed object", raw: `{"secret":"DO_NOT_ECHO"`},
{name: "trailing value", raw: `{} {}`},
{name: "line feed", raw: "{\n}"},
{name: "carriage return", raw: "{\r}"},
{name: "NUL", raw: "{\x00}"},
{name: "vertical tab", raw: "{\v}"},
{name: "form feed", raw: "{\f}"},
{name: "DEL", raw: "{\x7f}"},
{name: "C1 control", raw: "{\u0085}"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentExt(tc.raw)
if err == nil || got != "" {
t.Fatalf("parseAgentExt() = %q, %v; want validation error", got, err)
}
assertAgentMetadataValidationError(t, err, "invalid_agent_ext", tc.raw)
})
}
}
func assertAgentMetadataValidationError(t *testing.T, err error, reason, raw string) {
t.Helper()
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != reason {
t.Fatalf("error = category %q reason %q, want validation/%s", appErr.Category, appErr.Reason, reason)
}
if strings.Contains(raw, "DO_NOT_ECHO") && strings.Contains(err.Error(), "DO_NOT_ECHO") {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestCrossPlatformCoverageAgentMetadataConfigRegistrationAndMasking(t *testing.T) {
items := configmeta.All()
var versionItem, extItem *configmeta.ConfigItem
for i := range items {
switch items[i].Name {
case envDWSAgentVersion:
versionItem = &items[i]
case envDWSAgentExt:
extItem = &items[i]
}
}
if versionItem == nil || extItem == nil {
t.Fatalf("Agent metadata config registration missing: version=%v ext=%v", versionItem != nil, extItem != nil)
}
if versionItem.Category != configmeta.CategoryExternal || versionItem.Sensitive {
t.Fatalf("version config metadata = %#v", *versionItem)
}
if extItem.Category != configmeta.CategoryExternal || !extItem.Sensitive {
t.Fatalf("extension config metadata = %#v", *extItem)
}
const canary = `{"umt":"SENSITIVE_CANARY"}`
t.Setenv(envDWSAgentExt, canary)
got, ok := configmeta.Resolve(envDWSAgentExt)
if !ok || got == "" || strings.Contains(got, "SENSITIVE_CANARY") || got == canary {
t.Fatalf("sensitive extension was not masked: value=%q ok=%v", got, ok)
}
t.Setenv(envDWSAgentVersion, "9.8.7")
command := newConfigListCommand()
var output strings.Builder
command.SetOut(&output)
command.SetArgs([]string{"--category", string(configmeta.CategoryExternal), "--show-values", "--json"})
if err := command.Execute(); err != nil {
t.Fatalf("config list failed: %v", err)
}
rawOutput := output.String()
if !json.Valid([]byte(rawOutput)) {
t.Fatalf("config list emitted invalid JSON: %q", rawOutput)
}
if !strings.Contains(rawOutput, envDWSAgentVersion) || !strings.Contains(rawOutput, envDWSAgentExt) {
t.Fatalf("config list omitted Agent metadata variables: %s", rawOutput)
}
if strings.Contains(rawOutput, "SENSITIVE_CANARY") || strings.Contains(rawOutput, canary) {
t.Fatalf("config list leaked Agent extension: %s", rawOutput)
}
}
func TestCrossPlatformCoverageResolveMCPRequestHeadersScopesAndFinalizesAgentMetadata(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, " 1.2.3-rc.1 ")
t.Setenv(envDWSAgentExt, " { \"umt\": \"masked\", \"unknown\": true } ")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["X-Dws-Agent-Ver"] = "merge-must-not-win"
headers["X-Dws-Agent-Ext"] = `{"source":"merge"}`
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[transport.HeaderAgentVersion] = "credential-must-not-win"
headers[transport.HeaderAgentExt] = `{"source":"credential"}`
return headers
},
})
for name, headers := range map[string]map[string]string{
"shared identity": resolveIdentityHeaders(),
"A2A export": MCPIdentityHeaders(),
} {
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("%s leaked MCP-only metadata: %#v", name, headers)
}
}
headers := resolveMCPRequestHeaders()
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
t.Fatalf("%s = %q, want 1.2.3-rc.1", transport.HeaderAgentVersion, got)
}
if got := headers[transport.HeaderAgentExt]; got != `{"umt":"masked","unknown":true}` {
t.Fatalf("%s = %q", transport.HeaderAgentExt, got)
}
if got := headers[transport.HeaderVersion]; got != version {
t.Fatalf("%s = %q, want CLI version %q", transport.HeaderVersion, got, version)
}
if _, ok := headers["User-Agent"]; ok {
t.Fatal("Agent extension must not create or replace the standard User-Agent header")
}
for _, key := range []string{"umt", "miniwua", "ua", "x-dws-agent-umt", "x-dws-agent-miniwua", "x-dws-agent-ua"} {
if hasHeaderFold(headers, key) {
t.Fatalf("Agent extension was split into an extra header %q: %#v", key, headers)
}
}
// Library paths are best-effort: one invalid value is omitted without
// suppressing the other valid field or preserving hook-injected values.
t.Setenv(envDWSAgentExt, `{"secret":"DO_NOT_ECHO"`)
headers = resolveMCPRequestHeaders()
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
t.Fatalf("valid version was suppressed: %q", got)
}
if hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("invalid extension or hook value leaked: %#v", headers)
}
// Exercise the nil-map and empty-input library paths. An absent environment
// must not allocate a map, while an EXT-only value must allocate one and
// remain a single compact Header.
t.Setenv(envDWSAgentVersion, "")
t.Setenv(envDWSAgentExt, "")
if got := applyAgentMetadataHeaders(nil); got != nil {
t.Fatalf("empty metadata allocated headers: %#v", got)
}
t.Setenv(envDWSAgentExt, " { } ")
headers = applyAgentMetadataHeaders(nil)
if got := headers[transport.HeaderAgentExt]; got != "{}" {
t.Fatalf("EXT-only metadata = %q, want {}", got)
}
}
func TestCrossPlatformCoverageRootRejectsInvalidAgentMetadataBeforeEditionHook(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
tests := []struct {
name string
env string
value string
reason string
}{
{name: "version", env: envDWSAgentVersion, value: "DO_NOT ECHO", reason: "invalid_agent_version"},
{name: "extension", env: envDWSAgentExt, value: `{"secret":"DO_NOT_ECHO"`, reason: "invalid_agent_ext"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "")
t.Setenv(envDWSAgentExt, "")
t.Setenv(tc.env, tc.value)
headerHookCalled := false
afterHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headerHookCalled = true
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headerHookCalled = true
return headers
},
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
afterHookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatalf("root command accepted invalid %s", tc.env)
}
if headerHookCalled || afterHookCalled {
t.Fatalf("edition hook ran before %s validation", tc.env)
}
assertAgentMetadataValidationError(t, err, tc.reason, tc.value)
})
}
}
func TestCrossPlatformCoverageAgentMetadataProcessEntryValidationPrecedesRootConstruction(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want bool
}{
{name: "default JSON", args: []string{"version"}, want: true},
{name: "long JSON", args: []string{"version", "--format", "JSON"}, want: true},
{name: "long table", args: []string{"--format=table", "version"}, want: false},
{name: "short attached JSON", args: []string{"version", "-fjson"}, want: true},
{name: "short table", args: []string{"version", "-f", "table"}, want: false},
{name: "last wins", args: []string{"--format", "table", "version", "-f=json"}, want: true},
{name: "terminator", args: []string{"version", "--format", "table", "--", "--format", "json"}, want: false},
{name: "missing value", args: []string{"version", "--format"}, want: false},
} {
t.Run("presentation/"+tc.name, func(t *testing.T) {
if got := processArgsRequestJSON(tc.args); got != tc.want {
t.Fatalf("processArgsRequestJSON(%q) = %v, want %v", tc.args, got, tc.want)
}
})
}
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "")
sensitiveRaw := "{\"umt\":\"must-not-leak\"}\n"
t.Setenv(envDWSAgentExt, sensitiveRaw)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
extensionHookCalls := 0
edition.Override(&edition.Hooks{
Name: "presentation-test",
RegisterExtraCommands: func(*cobra.Command, edition.ToolCaller) {
extensionHookCalls++
},
VisibleProducts: func() []string {
extensionHookCalls++
return nil
},
StaticServers: func() []edition.ServerInfo {
extensionHookCalls++
return nil
},
})
oldArgs := os.Args
os.Args = []string{"dws", "version"}
t.Cleanup(func() { os.Args = oldArgs })
rootConstructed := false
preParseCalled := false
testseam.Swap(t, &rootNewRootCommandWithEngine, func(context.Context, *pipeline.Engine) *cobra.Command {
rootConstructed = true
return &cobra.Command{Use: "dws"}
})
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error {
preParseCalled = true
return nil
})
stderrFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stderr-*")
if err != nil {
t.Fatalf("create stderr capture: %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrFile
t.Cleanup(func() {
os.Stderr = oldStderr
_ = stderrFile.Close()
})
if code := Execute(); code == 0 {
t.Fatal("process entry accepted invalid Agent metadata")
}
if rootConstructed || preParseCalled {
t.Fatalf("invalid Agent metadata reached root hooks: constructed=%v preParse=%v", rootConstructed, preParseCalled)
}
if extensionHookCalls != 0 {
t.Fatalf("invalid Agent metadata executed %d extension hooks", extensionHookCalls)
}
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync stderr capture: %v", err)
}
stderrOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read stderr capture: %v", err)
}
if strings.Contains(string(stderrOutput), "must-not-leak") || strings.Contains(string(stderrOutput), sensitiveRaw) {
t.Fatalf("process validation error leaked raw EXT: %q", stderrOutput)
}
if !json.Valid(stderrOutput) || !strings.Contains(string(stderrOutput), `"reason": "invalid_agent_ext"`) {
t.Fatalf("default JSON error presentation = %q", stderrOutput)
}
stdoutFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stdout-*")
if err != nil {
t.Fatalf("create stdout capture: %v", err)
}
oldStdout := os.Stdout
os.Stdout = stdoutFile
t.Cleanup(func() {
os.Stdout = oldStdout
_ = stdoutFile.Close()
})
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
if err := stdoutFile.Sync(); err != nil {
t.Fatalf("sync stdout capture: %v", err)
}
unifiedOutput, err := os.ReadFile(stdoutFile.Name())
if err != nil {
t.Fatalf("read stdout capture: %v", err)
}
if !json.Valid(unifiedOutput) || !strings.Contains(string(unifiedOutput), `"outcome": "failure"`) ||
!strings.Contains(string(unifiedOutput), `"subtype": "invalid_agent_ext"`) {
t.Fatalf("unified JSON error presentation = %q", unifiedOutput)
}
if extensionHookCalls != 0 {
t.Fatalf("presentation-only root executed %d extension hooks", extensionHookCalls)
}
if err := stderrFile.Truncate(0); err != nil {
t.Fatalf("truncate fallback stderr capture: %v", err)
}
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind fallback stderr capture: %v", err)
}
testseam.Swap(t, &rootEmitResult, func(*cobra.Command, outputpkg.CommandResult) (int, error) {
return 0, errors.New("injected result emission failure")
})
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync fallback stderr capture: %v", err)
}
fallbackOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read fallback stderr capture: %v", err)
}
if !json.Valid(fallbackOutput) || !strings.Contains(string(fallbackOutput), `"reason": "invalid_agent_ext"`) ||
strings.Contains(string(fallbackOutput), "must-not-leak") {
t.Fatalf("fallback validation error presentation = %q", fallbackOutput)
}
if err := stderrFile.Truncate(0); err != nil {
t.Fatalf("truncate stderr capture: %v", err)
}
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind stderr capture: %v", err)
}
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"version", "--format", "table"})
if err := stderrFile.Sync(); err != nil {
t.Fatalf("sync human stderr capture: %v", err)
}
humanOutput, err := os.ReadFile(stderrFile.Name())
if err != nil {
t.Fatalf("read human stderr capture: %v", err)
}
if json.Valid(humanOutput) || !strings.Contains(string(humanOutput), "DWS_AGENT_EXT") ||
strings.Contains(string(humanOutput), "must-not-leak") {
t.Fatalf("human validation error presentation = %q", humanOutput)
}
var capturedRunner *runtimeRunner
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(flags *GlobalFlags) executor.Runner {
capturedRunner = newCommandRunnerWithFlags(flags).(*runtimeRunner)
return capturedRunner
})
cachedSnapshot := agentMetadataSnapshot{version: "9.8.7", ext: `{"ua":"cached"}`}
_ = newRootCommandWithMode(
contextWithAgentMetadataSnapshot(context.Background(), cachedSnapshot),
nil,
false,
true,
true,
)
if capturedRunner == nil || capturedRunner.agentMetadata == nil || *capturedRunner.agentMetadata != cachedSnapshot {
t.Fatalf("root runner Agent metadata = %#v, want %#v", capturedRunner, cachedSnapshot)
}
}
func TestCrossPlatformCoverageAgentMetadataExcludedFromServiceDiscovery(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "3.0.0")
t.Setenv(envDWSAgentExt, `{"umt":"test-value"}`)
headers := resolveMCPRequestHeadersForInvocation(executor.Invocation{
CanonicalProduct: mcpMetaServerID,
Tool: mcpMetaURLTool,
})
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
t.Fatalf("service-discovery request leaked Agent metadata: %#v", headers)
}
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"})
if headers[transport.HeaderAgentVersion] != "3.0.0" || headers[transport.HeaderAgentExt] == "" {
t.Fatalf("ordinary MCP request omitted Agent metadata: %#v", headers)
}
cached := agentMetadataSnapshot{version: "3.1.0", ext: "{}"}
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}, cached)
if headers[transport.HeaderAgentVersion] != "3.1.0" || headers[transport.HeaderAgentExt] != "{}" {
t.Fatalf("ordinary MCP request ignored its validated snapshot: %#v", headers)
}
}
func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSAgentVersion, "2.0.0")
t.Setenv(envDWSAgentExt, `{"ua":"test-agent/2.0"}`)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{})
pluginAuthMu.Lock()
oldPluginRegistry := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
pluginAuthMu.Lock()
pluginAuthRegistry = oldPluginRegistry
pluginAuthMu.Unlock()
})
dynamicMu.Lock()
oldDynamicEndpoints := dynamicEndpoints
oldDynamicProducts := dynamicProducts
oldDynamicAliases := dynamicAliases
oldDynamicToolEndpoints := dynamicToolEndpoints
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = oldDynamicEndpoints
dynamicProducts = oldDynamicProducts
dynamicAliases = oldDynamicAliases
dynamicToolEndpoints = oldDynamicToolEndpoints
dynamicMu.Unlock()
})
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
type capturedRequest struct {
headers map[string]string
token string
}
var captured []capturedRequest
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
copyHeaders := make(map[string]string, len(client.ExtraHeaders))
for key, value := range client.ExtraHeaders {
copyHeaders[key] = value
}
captured = append(captured, capturedRequest{headers: copyHeaders, token: client.AuthToken})
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
})
created := newCommandRunnerWithFlags(&GlobalFlags{}).(*runtimeRunner)
if hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentVersion) ||
hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentExt) {
t.Fatalf("new runner resolved Agent metadata before invocation validation: %#v", created.transport.ExtraHeaders)
}
// runSingle must not cache ambient MCP metadata on the shared base transport.
// Use mock mode to exercise the path without authentication or network I/O.
t.Setenv(envDWSAgentVersion, "2.0.1")
refreshRunner := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{Mock: true},
auditSink: audit.NopSink{},
}
refreshInvocation := executor.Invocation{CanonicalProduct: "refresh", Tool: "tool", Params: map[string]any{}}
if _, err := refreshRunner.runSingle(context.Background(), refreshInvocation, false); err != nil {
t.Fatalf("mock runSingle failed: %v", err)
}
if hasHeaderFold(refreshRunner.transport.ExtraHeaders, transport.HeaderAgentVersion) {
t.Fatalf("runSingle mutated the shared transport Header map: %#v", refreshRunner.transport.ExtraHeaders)
}
t.Setenv(envDWSAgentVersion, "2.0.0")
r := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{Token: "test-token"},
auditSink: audit.NopSink{},
agentMetadata: &agentMetadataSnapshot{
version: "2.0.0",
ext: `{"ua":"test-agent/2.0"}`,
},
}
builtIn := executor.Invocation{CanonicalProduct: "built-in", Tool: "tool", Params: map[string]any{}}
if _, err := r.executeInvocation(context.Background(), "https://example.test", builtIn); err != nil {
t.Fatalf("built-in invocation failed: %v", err)
}
pluginDescriptor := mcptypes.ServerDescriptor{
Key: "third-party",
Endpoint: "https://plugin.example.test",
CLI: mcptypes.CLIOverlay{ID: "third-party"},
AuthHeaders: map[string]string{
"X-Plugin": "yes",
"X-Dws-Agent-Ver": "plugin-must-not-forge-version",
"X-Dws-Agent-Ext": `{"source":"plugin"}`,
},
}
registerPluginHTTPServer(pluginDescriptor)
registeredPlugin, pluginOwned := LookupPluginAuth("third-party")
if !pluginOwned || registeredPlugin == nil || registeredPlugin.Token != "" {
t.Fatalf("anonymous HTTP plugin ownership = %#v, %v", registeredPlugin, pluginOwned)
}
registerPluginHTTPServer(mcptypes.ServerDescriptor{
Key: "anonymous-empty",
Endpoint: "https://anonymous.example.test",
CLI: mcptypes.CLIOverlay{ID: "anonymous-empty"},
})
if emptyPlugin, owned := LookupPluginAuth("anonymous-empty"); !owned || emptyPlugin == nil || emptyPlugin.Token != "" || len(emptyPlugin.ExtraHeaders) != 0 {
t.Fatalf("headerless HTTP plugin ownership = %#v, %v", emptyPlugin, owned)
}
originalPluginHeaders := maps.Clone(registeredPlugin.ExtraHeaders)
pluginInvocation := executor.Invocation{CanonicalProduct: "third-party", Tool: "tool", Params: map[string]any{}}
if _, err := r.executeInvocation(context.Background(), "https://plugin.example.test", pluginInvocation); err != nil {
t.Fatalf("plugin invocation failed: %v", err)
}
if len(captured) != 2 {
t.Fatalf("captured %d calls, want 2", len(captured))
}
if captured[0].headers[transport.HeaderAgentVersion] != "2.0.0" || captured[0].headers[transport.HeaderAgentExt] != `{"ua":"test-agent/2.0"}` {
t.Fatalf("built-in MCP metadata = %#v", captured[0].headers)
}
if hasHeaderFold(captured[1].headers, transport.HeaderAgentVersion) || hasHeaderFold(captured[1].headers, transport.HeaderAgentExt) {
t.Fatalf("plugin request leaked Agent metadata: %#v", captured[1].headers)
}
if got := captured[1].headers["X-Plugin"]; got != "yes" {
t.Fatalf("plugin-owned header = %q, want yes", got)
}
if captured[1].token != "" {
t.Fatalf("anonymous plugin unexpectedly received default OAuth token")
}
if !maps.Equal(registeredPlugin.ExtraHeaders, originalPluginHeaders) {
t.Fatalf("plugin Header sanitization mutated registry state: got %#v want %#v", registeredPlugin.ExtraHeaders, originalPluginHeaders)
}
if got := pluginRequestHeaders(nil); got != nil {
t.Fatalf("nil plugin auth produced Headers: %#v", got)
}
if got := pluginRequestHeaders(&PluginAuth{ExtraHeaders: map[string]string{
"X-DWS-AGENT-VER": "forged",
"X-DWS-AGENT-EXT": `{"forged":true}`,
}}); got != nil {
t.Fatalf("reserved-only plugin Headers survived sanitization: %#v", got)
}
// Keep the execution-boundary auth guard independently testable: even if a
// future token provider returns an empty token without an error, built-in MCP
// calls must fail before preflight or transport while anonymous plugins remain
// valid above.
resolveCalled := false
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
resolveCalled = true
return "", nil
})
callsBefore := len(captured)
unauthenticated := &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: &GlobalFlags{},
auditSink: audit.NopSink{},
}
if _, err := unauthenticated.executeInvocation(context.Background(), "https://example.test", executor.Invocation{CanonicalProduct: "built-in-unauthenticated", Tool: "tool"}); err == nil || !isAuthError(err) {
t.Fatalf("unauthenticated built-in request = %v, want auth error", err)
}
if !resolveCalled {
t.Fatal("unauthenticated request did not exercise the token resolver")
}
if len(captured) != callsBefore {
t.Fatalf("unauthenticated built-in request reached transport: calls %d -> %d", callsBefore, len(captured))
}
}
func hasHeaderFold(headers map[string]string, want string) bool {
for key := range headers {
if strings.EqualFold(key, want) {
return true
}
}
return false
}
+1 -1
View File
@@ -158,7 +158,7 @@ func TestApplyAgentProductHeader(t *testing.T) {
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
func TestCrossPlatformCoverageRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
+11 -14
View File
@@ -15,11 +15,10 @@ package app
import "sync"
// PluginAuth holds authentication credentials for a plugin-owned
// streamable-http MCP server. Each server is keyed by its canonical
// product ID (CLI.ID) so that different servers can use independent
// tokens without interfering with each other or with the default
// DingTalk OAuth token.
// PluginAuth marks ownership of a plugin-owned streamable-http MCP server and
// holds its optional authentication credentials. Every accepted HTTP plugin,
// including an anonymous one, has a non-nil record keyed by canonical product
// ID (CLI.ID) so execution never falls back to built-in DingTalk OAuth.
type PluginAuth struct {
// Token is the Bearer token extracted from the plugin's
// "Authorization" header (e.g. a third-party API key).
@@ -39,27 +38,25 @@ var (
pluginAuthRegistry = make(map[string]*PluginAuth)
)
// RegisterPluginAuth stores authentication credentials for a plugin
// server keyed by its canonical product ID. The runner looks up these
// credentials at execution time to inject the correct Bearer token
// instead of the default DingTalk OAuth token.
// RegisterPluginAuth stores ownership and optional authentication credentials
// for a plugin server keyed by its canonical product ID.
func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
// ClearPluginAuth removes the ownership and credential record for a plugin
// product.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
// LookupPluginAuth returns plugin ownership and optional authentication
// credentials for the product ID. The bool denotes ownership, not whether a
// Bearer token is present.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
pluginAuthMu.RLock()
defer pluginAuthMu.RUnlock()
+17 -9
View File
@@ -33,6 +33,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
state, readable, err := skillstate.Read(home)
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
t.Fatalf("multi dry run = %q, %v", output, err)
}
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
t.Fatalf("invalid setup %#v succeeded", args)
}
}
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("default mono setup: %v", err)
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("mono setup: %v", err)
}
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
t.Fatalf("default mode should be multi: %q, %v", output, err)
}
}
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing multi source succeeded")
}
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
t.Fatalf("default setup mode = %q, %v", mode, err)
}
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
_ = isSkillSourceRoot(tc.path, tc.mode)
}
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
}
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
_ = agentHomeForMode("base", skillSetupModeMulti)
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
_ = mutualExclusionVictims(root, mode)
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
_, _ = mutualExclusionVictims(root, mode)
}
if isCharDevice(nil) || isInteractiveTerminal() {
t.Fatal("test process unexpectedly interactive")
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
multiDest := filepath.Join(t.TempDir(), "agent")
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
badParent := filepath.Join(t.TempDir(), "file")
_ = os.WriteFile(badParent, []byte("x"), 0o600)
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
t.Fatal("copy missing directory succeeded")
}
+10 -5
View File
@@ -61,11 +61,16 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
}
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
fallback := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
if fallback == nil || fallback.Token != "token" || len(fallback.TrustedDomains) != 0 {
t.Fatalf("fallback plugin auth = %#v", fallback)
}
got := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
if got == nil || got.Token != "token" || got.ExtraHeaders["X"] != "Y" || len(got.TrustedDomains) != 2 {
t.Fatalf("plugin auth = %#v", got)
}
if anonymous := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "none"}); anonymous == nil || anonymous.Token != "" {
t.Fatalf("anonymous plugin ownership = %#v", anonymous)
}
}
+3 -3
View File
@@ -13,9 +13,9 @@
package app
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
// (agent identity, env trace headers, edition MergeHeaders). Intended for
// non-MCP transports such as the A2A gateway client.
// MCPIdentityHeaders returns the shared identity header map used by non-MCP
// transports such as the A2A gateway client. MCP-only Agent version and
// extension metadata are intentionally excluded.
func MCPIdentityHeaders() map[string]string {
return resolveIdentityHeaders()
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
+67 -5
View File
@@ -68,6 +68,45 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "list_files":
return `{"success":true,"result":{"files":[],"hasMore":false}}`
case "list_recycle_items":
return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}`
case "get_star_list":
return `{"success":true,"result":{"starList":[],"hasMore":false}}`
case "list_file_versions":
return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}`
case "get_file_info":
name := "Fixture Node"
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
switch call.tool {
case "create_folder":
if value, ok := call.args["name"].(string); ok {
name = value
}
index = -1
case "rename_document":
if value, ok := call.args["newName"].(string); ok {
name = value
}
index = -1
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}})
return string(encoded)
case "get_cover", "get_node_stats":
return `{"success":true,"result":{"nodeId":"node-1"}}`
case "get_file_publish_status":
return `{"success":true,"result":{"fileId":"node-1","published":false}}`
case "create_folder", "create_shortcut":
return `{"success":true,"fileId":"node-1"}`
case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version":
return `{"success":true,"fileId":"node-1"}`
case "set_file_publish":
return `{"success":true}`
case "download_file", "download_file_version":
return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
@@ -322,9 +361,9 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--to-user", appFixtureCurrentDOpenID,
"--text", "hello alias",
"--uuid", "alias-e2e",
"--idempotency-key", "alias-e2e",
)
if err != nil {
t.Fatalf("chat write alias E2E error = %v", err)
@@ -336,7 +375,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
@@ -350,6 +389,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
_, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--group", "fixture-conversation",
"--text", "hello legacy uuid",
"--uuid", "legacy-alias-e2e",
)
if err != nil {
t.Fatalf("chat message send legacy uuid error = %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" {
t.Fatalf("chat legacy uuid payload = %#v", payload)
}
if _, exists := payload["idempotency-key"]; exists {
t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload)
}
}
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
@@ -627,11 +689,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
@@ -4,14 +4,21 @@
package app
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
@@ -32,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -55,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
@@ -68,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
@@ -86,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
@@ -124,6 +131,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -131,9 +139,43 @@ var paramAliasCompleteCommands = map[string][]string{
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive +cover": {"drive", "+cover", "--node", "node-1"},
"drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"},
"drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"},
"drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"},
"drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"},
"drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"},
"drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"},
"drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"},
"drive +publish-get": {"drive", "+publish-get", "--node", "node-1"},
"drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"},
"drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"},
"drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"},
"drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"},
"drive +search": {"drive", "+search", "--query", "fixture"},
"drive +star-add": {"drive", "+star-add", "--node", "node-1"},
"drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"},
"drive +star-remove": {"drive", "+star-remove", "--node", "node-1"},
"drive +stats": {"drive", "+stats", "--node", "node-1"},
"drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"},
"drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"},
"drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"},
"drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"},
"drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"},
"drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"},
"drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"},
"drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"},
"drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"},
"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 message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
@@ -156,15 +198,29 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"doc +inspect": {
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
},
"doc +search": {
"created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"},
"created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"},
"creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"},
},
"drive list": {
"workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"},
"order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"},
"space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"},
"order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
@@ -241,12 +297,105 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasNewDriveCases is the exact executable-alias set introduced by the
// reviewed Drive expansion. Guard fixtures are covered separately by the
// exhaustive runtime-contract tests; every entry here must preserve the final
// transport payload of its canonical spelling.
var paramAliasNewDriveCases = []struct {
command string
emitted string
canonical string
}{
{command: "drive +cover", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +create-folder", emitted: "folder-name", canonical: "name"},
{command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"},
{command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"},
{command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"},
{command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"},
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +download", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +download", emitted: "destination-path", canonical: "output"},
{command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"},
{command: "drive +list", emitted: "folder-id", canonical: "folder"},
{command: "drive +list", emitted: "page-size", canonical: "limit"},
{command: "drive +list", emitted: "next-token", canonical: "cursor"},
{command: "drive +list", emitted: "sort-direction", canonical: "order"},
{command: "drive +list", emitted: "sort-by", canonical: "order-by"},
{command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +publish-unset", emitted: "file-id", canonical: "node"},
{command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"},
{command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
{command: "drive +rename", emitted: "file-id", canonical: "node"},
{command: "drive +rename", emitted: "new-name", canonical: "name"},
{command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +star-list", emitted: "max-results", canonical: "limit"},
{command: "drive +star-remove", emitted: "file-id", canonical: "node"},
{command: "drive +stats", emitted: "dentry-uuid", canonical: "node"},
{command: "drive +upload", emitted: "source-file", canonical: "file"},
{command: "drive +upload", emitted: "name", canonical: "file-name"},
{command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"},
{command: "drive +version-download", emitted: "version-number", canonical: "version"},
{command: "drive +version-download", emitted: "save-path", canonical: "output"},
{command: "drive +version-get", emitted: "version-no", canonical: "version"},
{command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"},
{command: "drive +version-history", emitted: "page-size", canonical: "limit"},
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
{command: "drive +cover", emitted: "node-id", canonical: "node"},
{command: "drive +create-shortcut", emitted: "node-id", canonical: "node"},
{command: "drive +delete", emitted: "node-id", canonical: "node"},
{command: "drive +download", emitted: "node-id", canonical: "node"},
{command: "drive +inspect", emitted: "node-id", canonical: "node"},
{command: "drive +publish-get", emitted: "node-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "node-id", canonical: "node"},
{command: "drive +rename", emitted: "node-id", canonical: "node"},
{command: "drive +star-add", emitted: "node-id", canonical: "node"},
{command: "drive +star-remove", emitted: "node-id", canonical: "node"},
{command: "drive +stats", emitted: "node-id", canonical: "node"},
{command: "drive +upload", emitted: "node-id", canonical: "node"},
{command: "drive +version-download", emitted: "node-id", canonical: "node"},
{command: "drive +version-get", emitted: "node-id", canonical: "node"},
{command: "drive +version-history", emitted: "node-id", canonical: "node"},
{command: "drive +version-revert", emitted: "node-id", canonical: "node"},
{command: "drive +cover", emitted: "url", canonical: "node"},
{command: "drive +create-shortcut", emitted: "document-id", canonical: "node"},
{command: "drive +delete", emitted: "folder-id", canonical: "node"},
{command: "drive +inspect", emitted: "document-id", canonical: "node"},
{command: "drive +inspect", emitted: "folder-id", canonical: "node"},
{command: "drive +publish-get", emitted: "url", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +rename", emitted: "document-id", canonical: "node"},
{command: "drive +rename", emitted: "folder-id", canonical: "node"},
{command: "drive +star-add", emitted: "url", canonical: "node"},
{command: "drive +star-remove", emitted: "doc-id", canonical: "node"},
{command: "drive +stats", emitted: "document-url", canonical: "node"},
{command: "drive +upload", emitted: "file-id", canonical: "node"},
}
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
// every Drive command in the expansion whose declared runtime safety requires
// confirmation. The full matrix below proves all spellings preserve the
// confirmed payload; this smaller matrix proves aliases cannot cross the
// confirmation boundary before any transport call is made.
var paramAliasNewDriveConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
{command: "drive +rename", emitted: "new-name", canonical: "name"},
{command: "drive +upload", emitted: "source-file", canonical: "file"},
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
// checked through the embedded PreParse delivery path and against a complete
// business-valid command template. The separate IM gate below continues to
// execute every alias introduced by the current IM optimization.
// business-valid command template. The dedicated product gates below continue
// to execute every alias introduced by the reviewed IM and Drive expansions.
//
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
// executing the complete 800+ command Root twice per spelling under -race.
@@ -261,6 +410,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
@@ -269,6 +419,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds
paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
@@ -278,6 +430,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
@@ -421,14 +574,118 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewDriveCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed Drive alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active {
return
}
if target != test.canonical {
t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeAliases != len(paramAliasNewDriveCases) {
t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases))
}
}
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewDriveConfirmationCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
}
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete)
}
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
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" {
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
})
}
}
// Some artifact commands deliberately continue past the final captured
// transport call into local download/upload handling. Deterministic invalid
// resource responses form their expected post-transport test boundary;
// canonical and alias calls must still produce the same request and error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
if err == nil {
return false
}
switch command {
case "chat +messages-resource-download":
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
case "drive +download", "drive +version-download":
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
case "drive +upload":
return strings.Contains(err.Error(), "incomplete drive upload credentials")
default:
return false
}
}
// +chat-messages supplies the current wall-clock time when callers omit
@@ -483,3 +740,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
}
return out, replacements
}
func removeExactArg(args []string, target string) ([]string, int) {
out := make([]string, 0, len(args))
removals := 0
for _, arg := range args {
if arg == target {
removals++
continue
}
out = append(out, arg)
}
return out, removals
}
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationTimeout = time.Second
patAuthorizationPollInterval = time.Millisecond
pollCtx, pollCancel := context.WithCancel(context.Background())
patResolveAccessToken = func(context.Context, string, string) (string, error) {
pollCancel()
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
+11
View File
@@ -30,6 +30,7 @@ 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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
@@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) {
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
// GetMCPBaseURL() returns the test server address.
func stubPATPollAccessToken(t *testing.T) {
t.Helper()
testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
})
}
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
t.Helper()
stubPATPollAccessToken(t)
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
}
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
stubPATPollAccessToken(t)
// When server returns 302 (SSO redirect), poll should continue until real response.
var callCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor
// It responds to device poll requests with the given status after the first poll.
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
t.Helper()
stubPATPollAccessToken(t)
var pollCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+133 -24
View File
@@ -79,6 +79,7 @@ var (
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
rootEmitResult = output.EmitResult
)
// Execute runs the root command and returns the process exit code.
@@ -136,6 +137,16 @@ func Execute() (exitCode int) {
restoreArgs := rootNormalizeProcessProfileArgs()
defer restoreArgs()
// Validate MCP Agent metadata before constructing the command tree.
// Construction may invoke edition registration/static-server hooks and load
// plugin PreParse handlers, so PersistentPreRunE alone is too late for the
// process entry point. Retain this exact pair for the eventual invocation.
agentMetadata := readAgentMetadataSnapshot()
if err := agentMetadata.validationError(); err != nil {
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
return apperrors.ExitCode(err)
}
timing := NewTimingCollector()
defer func() {
rootStopAllStdioClients() // Ensure child processes are terminated on exit
@@ -147,6 +158,7 @@ func Execute() (exitCode int) {
// Attach timing collector to context for use by child components
ctx := WithTimingCollector(context.Background(), timing)
ctx = contextWithAgentMetadataSnapshot(ctx, agentMetadata)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
@@ -260,6 +272,70 @@ func Execute() (exitCode int) {
return 0
}
// emitEarlyAgentMetadataValidationError preserves each built-in command's
// legacy-vs-unified output contract without running extension hooks. The
// presentation-only tree contains reviewed open-source commands and flags but
// deliberately omits edition registration, plugin loading, and visibility
// hooks; callers therefore still fail before any external hook executes.
func emitEarlyAgentMetadataValidationError(err error, args []string) {
format := processArgsFormat(args)
presentationRoot := newRootPresentationCommand()
_ = presentationRoot.PersistentFlags().Set("format", format)
if target, _, findErr := presentationRoot.Find(args); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
target.SetOut(os.Stdout)
target.SetErr(os.Stderr)
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
if _, emitErr := rootEmitResult(target, result); emitErr == nil {
return
}
}
if strings.EqualFold(strings.TrimSpace(format), "json") {
_ = apperrors.PrintJSON(os.Stderr, err)
return
}
_ = apperrors.PrintHumanAt(os.Stderr, err, apperrors.VerbosityNormal)
}
// processArgsRequestJSON preserves the CLI's machine-readable error contract
// for validation that must occur before Cobra and its presentation flags exist.
// The global format defaults to JSON; an explicit non-JSON format switches to
// the human diagnostic path. Last occurrence wins, matching pflag semantics.
func processArgsRequestJSON(args []string) bool {
return strings.EqualFold(strings.TrimSpace(processArgsFormat(args)), "json")
}
func processArgsFormat(args []string) string {
format := "json"
for index := 0; index < len(args); index++ {
arg := args[index]
if arg == "--" {
break
}
if value, ok := strings.CutPrefix(arg, "--format="); ok {
format = value
continue
}
if value, ok := strings.CutPrefix(arg, "-f="); ok {
format = value
continue
}
if strings.HasPrefix(arg, "-f") && len(arg) > len("-f") {
format = strings.TrimPrefix(arg, "-f")
continue
}
if arg != "--format" && arg != "-f" {
continue
}
if index+1 >= len(args) {
format = ""
break
}
index++
format = args[index]
}
return format
}
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
@@ -633,12 +709,25 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
return newRootCommandWithMode(rootCtx, engine, loadRuntimeExtensions, declarationOnly, false)
}
func newRootPresentationCommand() *cobra.Command {
return newRootCommandWithMode(context.Background(), nil, false, true, true)
}
func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool, presentationOnly bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
runner := rootNewCommandRunnerWithFlags(flags)
if snapshot, ok := agentMetadataSnapshotFromContext(rootCtx); ok {
if runtime, ok := runner.(*runtimeRunner); ok {
runtime.agentMetadata = &snapshot
}
}
root := &cobra.Command{
Use: "dws",
@@ -672,15 +761,34 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
// Validate caller-provided identity and MCP metadata before command
// execution hooks or network activity. The process entry point additionally
// validates Agent metadata before command-tree construction; direct Cobra
// embedding retains this execution-boundary guard.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context())
if !cached {
agentMetadata = readAgentMetadataSnapshot()
}
if err := agentMetadata.validationError(); err != nil {
return err
}
if runtime, ok := runner.(*runtimeRunner); ok {
// Retain the exact validated pair for this command execution so a
// concurrently mutating embedding environment cannot change what is
// later applied after edition and credential hooks.
runtime.agentMetadata = &agentMetadata
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
}
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
@@ -772,10 +880,12 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
if !presentationOnly {
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
}
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
@@ -786,7 +896,9 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
if !presentationOnly {
hideNonDirectRuntimeCommands(root)
}
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
@@ -1750,17 +1862,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool {
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
// Register ownership for every accepted HTTP plugin, including anonymous
// plugins. Execution must never fall back to the built-in DingTalk OAuth or
// Agent-metadata path merely because a plugin has no Authorization Header.
RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv))
}
// registerPluginAuthFromHeaders extracts authentication credentials from
// a server descriptor's AuthHeaders and registers them in the global
// PluginAuth registry. The runner uses this registry at execution time
// to inject the correct Bearer token for third-party MCP servers.
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom
// Headers. A non-nil result also acts as the HTTP plugin ownership marker for
// anonymous plugins.
func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1771,20 +1882,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
extraHeaders[key] = value
}
}
if authToken == "" {
return
}
var trustedDomains []string
if parsed, err := url.Parse(srv.Endpoint); err == nil {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
if host != "" {
trustedDomains = []string{host, "*." + host}
}
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
return &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
TrustedDomains: trustedDomains,
})
}
}
// newPipelineEngine creates and configures the pipeline engine with
+15
View File
@@ -22,6 +22,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -369,6 +370,20 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
t.Fatalf("chat message send missing --%s", flag)
}
}
idempotencyKey := send.Flags().Lookup("idempotency-key")
if idempotencyKey == nil {
t.Fatal("chat message send missing --idempotency-key")
}
legacyUUID := send.Flags().Lookup("uuid")
if legacyUUID == nil || !legacyUUID.Hidden {
t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID)
}
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" {
t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got)
}
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1)
}
got, err := executeRootCaptureStdout(t, []string{
"chat", "file", "upload",
+26 -13
View File
@@ -137,7 +137,6 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
transportClient := transport.NewClient(httpClient)
transportClient.ExtraHeaders = resolveIdentityHeaders()
transportClient.FileLogger = FileLoggerInstance()
return &runtimeRunner{
transport: transportClient,
@@ -156,12 +155,14 @@ type runtimeRunner struct {
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
agentMetadata *agentMetadataSnapshot
}
var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
@@ -237,7 +238,6 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
if r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
r.transport.ExtraHeaders = resolveIdentityHeaders()
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
if r.globalFlags != nil && r.globalFlags.Mock {
@@ -543,9 +543,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
}()
// Check if this product has plugin-level auth credentials registered.
// If so, use the plugin's token instead of the default DingTalk OAuth token.
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
// Check whether this product belongs to an HTTP plugin. Every accepted
// plugin has an ownership record; credentials within that record are
// optional. Plugin requests never fall back to the default DingTalk OAuth.
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
authToken := ""
@@ -553,7 +553,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
authToken = pluginAuth.Token
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error
authToken, tokenErr = r.resolveAuthToken(ctx)
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr)
}
@@ -603,9 +603,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
// Fail-fast: reject unauthenticated requests before making network calls.
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
if strings.TrimSpace(authToken) == "" {
// Preserve a final execution-boundary guard even though the built-in token
// resolver normally returns either a non-empty token or an error. HTTP
// plugins are ownership-scoped separately and may intentionally be anonymous.
if !hasPluginAuth && strings.TrimSpace(authToken) == "" {
return executor.Result{}, apperrors.NewAuth(
"未登录,请先执行 dws auth login",
apperrors.WithReason("not_authenticated"),
@@ -616,12 +617,20 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
var tc *transport.Client
if hasPluginAuth {
// Use plugin-level auth: inject the plugin's token and trust its domains.
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
// Plugin ownership is authoritative even when the plugin is anonymous.
// Copy and sanitize manifest headers so plugins cannot opt themselves into
// DWS-owned Agent metadata by declaring the reserved names directly.
tc = r.transport.WithAuth(authToken, pluginRequestHeaders(pluginAuth))
tc.TrustedDomains = pluginAuth.TrustedDomains
} else {
// Default path: use DingTalk OAuth token with identity headers.
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
// Default path: use DingTalk OAuth token with identity headers. Agent
// metadata is resolved exactly once per invocation and is excluded from
// helper-only service-discovery requests.
if r.agentMetadata != nil {
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation, *r.agentMetadata))
} else {
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation))
}
}
callCtx := ctx
@@ -1069,6 +1078,10 @@ func resolveIdentityHeaders() map[string]string {
} else {
delete(headers, agentproduct.HeaderName)
}
// Agent version and extension are intentionally MCP-request-only. Remove
// every case variant potentially supplied by an edition or credential hook
// so shared consumers such as A2A cannot inherit them.
removeAgentMetadataHeaders(headers)
return headers
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 399
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 399
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
+1
View File
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
+1 -1
View File
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
// Should contain all predefined targets — including the agents/* sentinel
// and the IDE/agent registries we share with skillSetupAgentHomes.
expectedTargets := []string{
"agents", "claude", "cursor", "codex", "opencode", "qoder",
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
"gemini", "github", "windsurf", "augment", "cline",
"amp", "kiro", "trae", "openclaw", "hermes",
".",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,707 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func useManagedSkillNames(t *testing.T, names ...string) {
t.Helper()
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
records = append(records, skillprovenance.Record{Name: name})
}
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: records}, true, nil
})
}
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
// full (unfiltered) multi install will back up and remove, and that a
// filtered install previews nothing extra.
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
dest := filepath.Join(t.TempDir(), ".claude", "skills")
stale := filepath.Join(dest, "dingtalk-old")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, "dingtalk-old")
var out bytes.Buffer
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("full install preview must list stale skills, got %q", out.String())
}
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
t.Fatalf("preview must name the stale directory, got %q", out.String())
}
out.Reset()
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
if err == nil || ok {
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
}
}
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if isManagedDWSMultiSkillDir(dir) {
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
}
managed := map[string]bool{"dingtalk-custom": true}
if !isManagedDWSMultiSkillDir(dir, managed) {
t.Fatal("unified metadata must prove ownership")
}
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
if !isManagedDWSMultiSkillDir(legacy) {
t.Fatal("the exact legacy dws-shared name must remain managed")
}
}
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
skillSrc := filepath.Join(src, "dingtalk-a")
failure := errors.New("publish denied")
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
t.Fatalf("mkdir error = %v", err)
}
})
t.Run("copy", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) {
t.Fatalf("copy error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("rename", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
t.Fatalf("rename error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("cleanup", func(t *testing.T) {
renameErr := errors.New("rename denied")
cleanupErr := errors.New("cleanup denied")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("cleanup error = %v", err)
}
})
}
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
// cleanupMutualExclusion keeps every victim in place with a warning when
// $HOME cannot be resolved, instead of destroying anything.
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
homeErr := errors.New("home boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
var out, errOut bytes.Buffer
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
t.Fatalf("victim must survive the HOME failure: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dws" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
t.Fatalf("missing whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if dir != victim {
t.Fatalf("backup victim = %q, want %q", dir, victim)
}
return "", failure
})
var out, errOut bytes.Buffer
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v, want %v", err, failure)
}
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("multi backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-a" {
return "", failure
}
return "", nil
})
base := filepath.Join(home, ".agents", "skills")
multi := filepath.Join(base, "dingtalk-a")
if err := os.MkdirAll(multi, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(multi))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if copyCalls != 1 {
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
}
if _, err := os.Stat(multi); err != nil {
t.Fatalf("multi leftover must survive backup failure: %v", err)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("stale backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-stale" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
stale := filepath.Join(dest, "dingtalk-stale")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(stale))
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
for _, failureKind := range []string{"later_backup", "later_publish"} {
failureKind := failureKind
t.Run(failureKind, func(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
first := filepath.Join(dest, "dingtalk-first")
second := filepath.Join(dest, "dingtalk-second")
for path, body := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
failure := errors.New("injected " + failureKind + " failure")
if failureKind == "later_backup" {
originalBackup := skillSetupBackupAndRemove
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
if dir == second {
return "", failure
}
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{"dingtalk-first", "dingtalk-second"},
[]string{dest},
&out,
&errOut,
true,
)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
for path, want := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
got, readErr := os.ReadFile(path)
if readErr != nil || string(got) != want {
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
}
}
entries, readErr := os.ReadDir(dest)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
t.Fatalf("transaction left staging directory %s", entry.Name())
}
}
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
t.Fatalf("transaction failure output = %q", errOut.String())
}
})
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
failure := errors.New("injected transaction failure")
t.Run("managed publish success", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := filepath.Join(t.TempDir(), "dingtalk-a")
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("published Skill missing: %v", err)
}
})
t.Run("staging cleanup failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
cleanupErr := errors.New("staging cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
t.Fatalf("staging cleanup error = %v", err)
}
})
t.Run("staging directory failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
originalMkdirAll := skillSetupMkdirAll
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
return failure
}
return originalMkdirAll(path, mode)
})
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
t.Fatalf("staging directory error = %v", err)
}
})
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
t.Run("original still exists", func(t *testing.T) {
original := t.TempDir()
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
t.Fatalf("existing restore target error = %v", err)
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
t.Fatalf("restore mkdir error = %v", err)
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
}
})
})
t.Run("backup rollback failure", func(t *testing.T) {
calls := 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
calls++
if calls == 1 {
return "backup", nil
}
return "", failure
})
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
}
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("publish rollback error = %v", err)
}
})
t.Run("execute cleanup errors", func(t *testing.T) {
newPlan := func(t *testing.T) *skillSetupPlan {
t.Helper()
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
return &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: src,
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
}
}
t.Run("after backup failure", func(t *testing.T) {
plan := newPlan(t)
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
cleanupErr := errors.New("cleanup after backup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after success", func(t *testing.T) {
plan := newPlan(t)
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after success")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
}
})
})
}
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
// install paths skip (never destroy) every target when $HOME cannot be
// resolved for the pre-refresh backup.
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(monoDest, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("existing mono dir must be preserved: %v", err)
}
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
t.Fatal(err)
}
out.Reset()
errOut.Reset()
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
t.Fatalf("existing sub skill must be preserved: %v", err)
}
}
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
// scan failures, the HOME failure, backup failures, and the success path.
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
keep := []string{"dingtalk-chat"}
entries := map[string]bool{ // dir entries; README below is a plain file
"dingtalk-chat": true, // kept (in bundle)
"dingtalk-stale": true, // stale product skill
"dws-shared": true, // legacy shared name is stale too
"other-skill": true, // non-DWS, must survive
}
for name := range entries {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
// Non-ENOENT scan failure warns; ENOENT is silent.
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
t.Fatalf("expected scan warning, got %q", errOut.String())
}
errOut.Reset()
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if errOut.Len() != 0 {
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
}
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
// The preview companion sees the same victims and skips files/kept/non-DWS.
victims := staleMultiSkillVictims(dest, keep)
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
if len(victims) != len(wantVictims) {
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
}
// HOME failure keeps every stale directory with a warning.
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning, got %q", errOut.String())
}
for name := range entries {
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
// Backup failure keeps the stale directory with a warning.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
t.Fatalf("expected backup failure warning, got %q", errOut.String())
}
for _, stale := range wantVictims {
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive the backup failure: %v", err)
}
}
// Success: both stale dirs are backed up and reported; the rest survives.
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
out.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
}
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
t.Fatalf("non-DWS dir must survive: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
t.Fatalf("bundle skill must survive: %v", err)
}
}
+39 -31
View File
@@ -47,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
oldTargets := skillSetupResolveTargets
oldList := skillSetupListMulti
oldFilter := skillSetupFilterMulti
oldConfirm := skillSetupConfirm
oldMono := skillSetupInstallMono
oldMulti := skillSetupInstallMulti
oldConfirm := skillSetupConfirmPlan
oldExecute := skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode = oldMode
skillSetupResolveSource = oldSource
skillSetupResolveTargets = oldTargets
skillSetupListMulti = oldList
skillSetupFilterMulti = oldFilter
skillSetupConfirm = oldConfirm
skillSetupInstallMono = oldMono
skillSetupInstallMulti = oldMulti
skillSetupConfirmPlan = oldConfirm
skillSetupExecutePlan = oldExecute
})
fail := errors.New("failure")
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
@@ -83,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
cmd.Flags().Bool("dry-run", true, "")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("confirmation failure should propagate")
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -105,17 +103,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
t.Fatal("unknown resolved mode should fail")
}
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("mono install failure should propagate")
}
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi install failure should propagate")
@@ -123,6 +121,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
setTestHome(t, t.TempDir())
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
@@ -143,6 +142,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{home},
&out,
&errOut,
true,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
@@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
if !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
@@ -178,12 +178,17 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{failureHome},
&failureOut,
&failureErr,
true,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
if readErr != nil || string(got) != "legacy\n" {
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
}
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
}
})
}
@@ -226,6 +231,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldBackup := skillSetupBackupAndRemove
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
@@ -234,6 +240,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupBackupAndRemove = oldBackup
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
@@ -246,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
t.Fatal("interactive mode failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
t.Fatalf("interactive default choice = %q, %v", got, err)
}
@@ -305,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
var out, errOut bytes.Buffer
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
t.Fatal("confirmation form failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
t.Fatalf("EOF confirmation = %v, %v", ok, err)
}
skillSetupRemoveAll = func(string) error { return fail }
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
skillSetupCopyDir = func(string, string) error { return fail }
skillSetupRemoveAll = func(string) error { return fail }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono remove failure not skipped")
t.Fatal("mono backup failure not skipped")
}
skillSetupRemoveAll = func(string) error { return nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
if skipped != 1 {
@@ -334,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 2 {
t.Fatal("multi mkdir failure count mismatch")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi remove failure count mismatch")
t.Fatal("multi backup failure count mismatch")
}
skillSetupRemoveAll = func(string) error { return nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi copy failure count mismatch")
}
@@ -516,15 +524,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
+425
View File
@@ -0,0 +1,425 @@
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
)
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".claude", "skills")
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
wantBackups := []string{
filepath.Join(dest, "dingtalk-a"),
filepath.Join(dest, "dingtalk-stale"),
filepath.Join(dest, "dws"),
}
// Dry-run must disclose every exact path and perform no backup or copy.
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatal(err)
}
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(dryRunOut.String(), path) != 1 {
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// The real confirmation renderer discloses the same paths. Its default
// negative answer must leave backup and copy at zero calls.
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var declineOut bytes.Buffer
declineCmd.SetOut(&declineOut)
if err := declineCmd.RunE(declineCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(declineOut.String(), path) != 1 {
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// Explicit confirmation executes exactly the paths rendered from the plan.
var confirmedPlan *skillSetupPlan
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
confirmedPlan = plan
renderSkillSetupPlan(out, plan)
return true, nil
})
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
t.Fatal(err)
}
var planned []string
for _, target := range confirmedPlan.Targets {
for _, backup := range target.Backups {
planned = append(planned, backup.Path)
}
}
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
}
if copyCalls != 2 {
t.Fatalf("copy calls = %d, want 2", copyCalls)
}
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
}
var filteredPaths []string
for _, backup := range filtered.Targets[0].Backups {
filteredPaths = append(filteredPaths, backup.Path)
}
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
t.Fatalf("filtered backups = %v", filteredPaths)
}
}
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
t.Fatalf("mono plan = %#v", plan)
}
}
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
}
})
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
t.Run("backup failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
// "dws" is synthetic but makes the mutual-exclusion target and selected
// same-name target overlap, pinning path deduplication in the plan itself.
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
t.Fatalf("deduplicated plan = %#v", plan)
}
failure := errors.New("scan denied")
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
failure := errors.New("stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
installed, skipped, err := installMultiSkillToHomes(
"source",
[]string{"dingtalk-a"},
[]string{filepath.Join(t.TempDir(), "skills")},
io.Discard,
io.Discard,
true,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
failure := errors.New("scan denied")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
return nil, failure
})
installed, skipped, err := installSkillToHomes(
"source",
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
t.Run("legacy shared stat failure", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
failure := errors.New("legacy stat denied")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(dest, legacySharedSkill) {
return nil, failure
}
return nil, os.ErrNotExist
})
_, err := buildSkillSetupPlan(
skillSetupModeMulti,
"source",
[]string{dest},
[]string{multiSharedSkill},
true,
)
if !errors.Is(err, failure) {
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
}
})
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
unrelated := filepath.Join(dest, "dws")
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
{
Destination: dest,
Backups: []skillSetupBackup{
{Path: filepath.Join(dest, multiEventSkill)},
{Path: filepath.Join(dest, multiMiscSkill)},
{Path: unrelated},
},
},
}}
configureEventMiscMigrationPlan(plan, []string{dest}, true)
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
}
})
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
called := false
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
called = true
if !filtered {
t.Fatal("filtered flag was not forwarded")
}
return 1, 2, nil
})
installed, skipped, err := installMultiSkillsWithEventMigration(
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
)
if err != nil || installed != 1 || skipped != 2 || !called {
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
}
})
t.Run("migration cleanup scan failure", func(t *testing.T) {
failure := errors.New("cleanup stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
dest := filepath.Join(t.TempDir(), "skills")
installed, skipped, err := installMultiSkillsWithEventMigration(
"source",
[]string{multiEventSkill, multiSharedSkill},
[]string{dest},
[]string{dest},
true,
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
dest := t.TempDir()
var out, errOut bytes.Buffer
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
plan := &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: "source",
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{
Destination: dest,
Backups: []skillSetupBackup{{
Path: filepath.Join(dest, "dingtalk-a"),
Reason: skillSetupBackupReplace,
}},
}},
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", errors.New("backup denied")
})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("same-name warning = %q", errOut.String())
}
}
+190
View File
@@ -0,0 +1,190 @@
package app
import (
"errors"
"io"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
if plan.Mode == skillSetupModeMono {
return 1, 0, nil
}
return 2, 0, nil
})
testseam.Swap(t, &skillSetupNow, func() time.Time {
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
})
var saved skillstate.State
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
saved = state
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
t.Fatalf("saved = %#v", saved)
}
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
t.Fatalf("write-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
t.Fatalf("remove-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
t.Fatalf("home error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
stateErr := errors.New("state denied")
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
err := cmd.RunE(cmd, nil)
if !errors.Is(err, stateErr) || executed != 0 {
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
}
}
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
for _, tc := range []struct {
name string
filtered bool
buildError error
homeError error
}{
{name: "digest", buildError: errors.New("digest denied")},
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
if tc.buildError != nil {
return skillprovenance.Record{}, tc.buildError
}
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
if tc.homeError != nil {
return "", tc.homeError
}
return home, nil
})
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if tc.filtered {
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || executed != 0 {
t.Fatalf("preflight = err %v, executed %d", err, executed)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
return []string{filepath.Join(home, "skills")}, nil
})
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
return 2, 1, nil
})
writes := 0
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
writes++
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
t.Fatalf("partial setup error = %v", err)
}
if writes != 0 {
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
}
}
+424 -24
View File
@@ -9,6 +9,10 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestSkillSetupCommandRegistered(t *testing.T) {
@@ -25,6 +29,189 @@ func TestSkillSetupCommandRegistered(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation guards
// the P1 review finding: copyable examples must preview or enter the normal
// confirmation path, never carry the scripting-only confirmation bypass.
func TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation(t *testing.T) {
cmd := newSkillSetupCommand()
var examples []string
for _, line := range strings.Split(cmd.Example, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
examples = append(examples, line)
if strings.Contains(line, "--yes") {
t.Fatalf("skill setup example bypasses confirmation: %q", line)
}
}
if len(examples) != 2 || !strings.Contains(examples[0], "--dry-run") || strings.Contains(examples[1], "--dry-run") {
t.Fatalf("examples must show preview then interactive confirmation: %v", examples)
}
}
func TestCrossPlatformCoverageSkillSetupHelpDescribesFullUpgradeRefresh(t *testing.T) {
help := newSkillSetupCommand().Long
for _, want := range []string{"每次 dws upgrade", "全量覆盖预制 skill", "本地删除", "会在升级时恢复"} {
if !strings.Contains(help, want) {
t.Fatalf("skill setup help missing full-refresh contract %q:\n%s", want, help)
}
}
for _, stale := range []string{"跳过本地已删除", "--force 恢复全量"} {
if strings.Contains(help, stale) {
t.Fatalf("skill setup help still advertises retired incremental behavior %q:\n%s", stale, help)
}
}
}
// TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves verifies
// the destructive half of the setup contract: when the user declines the
// confirmation prompt, nothing is installed and nothing is removed (neither
// the opposite-mode leftovers nor stale skills). Confirming must then run the
// exact cleanup previewed earlier: leftovers are backed up to
// ~/.dws/skill-backups/ before they disappear.
func TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable", "dingtalk-calendar"})
agentHome := filepath.Join(home, ".claude", "skills")
// Opposite-mode leftover (mono dws/) plus a stale multi skill the full
// install would clean; both must survive a declined confirmation.
for _, leftover := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if err := os.MkdirAll(leftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(leftover, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
oldConfirm := skillSetupConfirmPlan
t.Cleanup(func() { skillSetupConfirmPlan = oldConfirm })
// Declined confirmation: nothing may change on disk.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd := newSkillSetupCommand()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("declined setup should succeed as a no-op: %v (%s)", err, errOut.String())
}
if !strings.Contains(out.String(), "已取消") {
t.Fatalf("expected cancellation notice, got %q", out.String())
}
for _, survivor := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(filepath.Join(survivor, "SKILL.md")); err != nil {
t.Fatalf("declined confirmation removed %s: %v", survivor, err)
}
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("declined confirmation must not install either: %v", err)
}
// Confirmed: the previewed victims are backed up + removed, bundle skills land.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return true, nil }
out.Reset()
errOut.Reset()
cmd = newSkillSetupCommand()
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("confirmed setup failed: %v (%s)", err, errOut.String())
}
for _, gone := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Fatalf("confirmed setup should remove %s (stat err=%v)", gone, err)
}
}
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
t.Fatalf("confirmed setup missing %s: %v", n, err)
}
}
// Every removal went through the reversible backup path, not a hard delete.
backupRoot := filepath.Join(home, ".dws", "skill-backups")
entries, err := os.ReadDir(backupRoot)
if err != nil || len(entries) == 0 {
t.Fatalf("confirmed setup must preserve victims under %s (entries=%v, err=%v)", backupRoot, entries, err)
}
if !strings.Contains(out.String(), "已备份并清理对面模式残留") || !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("expected backup-and-remove log lines, got %q", out.String())
}
}
// TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes pins the real
// non-TTY safety boundary: an explicit mode alone is not consent to move
// directories. The same command with --yes performs the previewed backup and
// installation.
func TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
oldInteractive := skillSetupInteractive
skillSetupInteractive = func() bool { return false }
t.Cleanup(func() { skillSetupInteractive = oldInteractive })
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable"})
agentHome := filepath.Join(home, ".claude", "skills")
mono := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
run := func(extra ...string) error {
cmd := newSkillSetupCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := []string{"--mode", "multi", "--target", "claude", "--source", multiSrc}
cmd.SetArgs(append(args, extra...))
return cmd.Execute()
}
if err := run(); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("non-interactive setup error = %v, want explicit --yes requirement", err)
}
if data, err := os.ReadFile(filepath.Join(mono, "SKILL.md")); err != nil || string(data) != "keep-me" {
t.Fatalf("unconfirmed setup changed mono (data=%q, err=%v)", string(data), err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup installed multi, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup created backup state, stat err=%v", err)
}
if err := run("--yes"); err != nil {
t.Fatalf("explicitly confirmed setup failed: %v", err)
}
if _, err := os.Stat(mono); !os.IsNotExist(err) {
t.Fatalf("confirmed setup kept mono, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("confirmed setup did not install multi: %v", err)
}
backupFound := false
_ = filepath.Walk(filepath.Join(home, ".dws", "skill-backups"), func(path string, info os.FileInfo, walkErr error) error {
if walkErr == nil && info != nil && !info.IsDir() && info.Name() == "SKILL.md" {
if data, readErr := os.ReadFile(path); readErr == nil && string(data) == "keep-me" {
backupFound = true
}
}
return nil
})
if !backupFound {
t.Fatal("confirmed setup did not back up mono")
}
}
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
if err != nil || got != skillSetupModeMono {
@@ -39,14 +226,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
}
}
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
var buf bytes.Buffer
got, err := resolveSkillSetupMode("", true, &buf)
if err != nil || got != skillSetupModeMono {
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
if err != nil || got != skillSetupModeMulti {
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
}
if !strings.Contains(buf.String(), "mono") {
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
if !strings.Contains(buf.String(), "multi") {
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
}
}
@@ -143,7 +330,51 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
}
}
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
}
func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
if err := os.MkdirAll(filepath.Join(home, ".zcode"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
if err != nil {
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
}
}
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
t.Fatal(err)
@@ -205,7 +436,7 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
return root
}
func TestInstallMultiSkillToHomes(t *testing.T) {
func TestCrossPlatformCoverageInstallMultiSkillToHomes(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
src := writeMultiSkillSource(t, names)
@@ -221,7 +452,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("installMultiSkillToHomes err: %v", err)
}
@@ -245,12 +476,14 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
}
}
func TestSkillSetupMutualExclusion(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMutualExclusion(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
src := writeMultiSkillSource(t, names)
// Simulate a pre-existing mono install under <agent-home>/dws/
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
monoLeftover := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
t.Fatal(err)
@@ -265,13 +498,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
// Confirm mutualExclusionVictims sees the leftover
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
if vErr != nil {
t.Fatalf("mutualExclusionVictims err: %v", vErr)
}
if len(victims) != 1 || victims[0] != monoLeftover {
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -290,7 +526,7 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
}
// the cleanup line should appear in stdout (best-effort observability)
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
}
@@ -318,11 +554,48 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("mono SKILL.md missing: %v", err)
}
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoPreservesUnregisteredDingtalkSkill(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
base := filepath.Join(home, ".agents", "skills")
managed := filepath.Join(base, "dingtalk-managed-old")
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
custom := filepath.Join(base, "dingtalk-custom")
for _, dir := range []string{managed, legacyOfficial, custom} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, filepath.Base(managed))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 1 || skipped != 0 {
t.Fatalf("mono install = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(managed); !os.IsNotExist(err) {
t.Fatalf("centrally managed DWS multi Skill must be removed during mono switch: %v", err)
}
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
t.Fatalf("pre-state official multi Skill must be removed during mono switch: %v", err)
}
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
t.Fatalf("unregistered market/user dingtalk-* Skill changed: data=%q err=%v", got, err)
}
}
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
// cache populated by install.sh / install.ps1 / npm install.js is part of the
// fallback candidate list, so `dws skill setup` can find a source on a fresh
@@ -464,10 +737,11 @@ func TestFilterMultiSkillNames(t *testing.T) {
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
// touch already-installed dingtalk-* siblings (additive semantics).
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
})
setTestHome(t, t.TempDir())
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
// Pretend the user already installed two dingtalk-* skills earlier.
@@ -493,7 +767,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -561,6 +835,135 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
// multi install removes stale dingtalk-* / dws-shared directories that are no
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
// The additive counterpart (filtered install) is covered by
// TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings.
func TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale(t *testing.T) {
names := []string{"dingtalk-aitable"}
src := writeMultiSkillSource(t, names)
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
dir := filepath.Join(agentHome, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
custom := filepath.Join(agentHome, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
if installed != 1 || skipped != 0 {
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Errorf("missing installed skill: %v", err)
}
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
}
}
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
}
if body, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(body) != "market skill" {
t.Errorf("unregistered market/user dingtalk-* dir must survive (body=%q, err=%v)", string(body), err)
}
if !strings.Contains(stdout.String(), "已备份并清理过期 skill") {
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
}
}
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
// surfaces as an errOut warning instead of silently skipping cleanup.
func TestCrossPlatformCoverageSkillSetupMutualExclusionScanWarning(t *testing.T) {
oldReadDir := skillSetupReadDir
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
scanFail := errors.New("scan boom")
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
t.Fatal("scan failure should surface as an error")
}
var out, errOut bytes.Buffer
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
}
}
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
// full install cleans stale siblings while a filtered install stays additive.
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
oldList, oldFilter, oldExecute := skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan = oldList, oldFilter, oldExecute
})
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
skillSetupResolveTargets = func(string, string) ([]string, error) {
return []string{filepath.Join(t.TempDir(), "dest")}, nil
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
skillSetupFilterMulti = filterMultiSkillNames
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
var gotFiltered []bool
skillSetupExecutePlan = func(plan *skillSetupPlan, _, _ io.Writer) (int, int, error) {
gotFiltered = append(gotFiltered, plan.Filtered)
return 1, 0, nil
}
// Full install (no -s/-x): filtered must be false.
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("full install run err: %v", err)
}
// Filtered install: filtered must be true.
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("filtered install run err: %v", err)
}
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
@@ -633,7 +1036,7 @@ func assertNoEventMigrationStages(t *testing.T, agentHome string) {
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -653,13 +1056,10 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
@@ -703,7 +1103,7 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -854,7 +1254,7 @@ func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
@@ -891,7 +1291,7 @@ func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"path/filepath"
"github.com/spf13/cobra"
)
// detectNestedMultiSkillLayout detects the compatibility gap where an older
// running dws process replaces itself with a newer binary, but then installs
// the new release bundle with its legacy mono copier. That produces the
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
//
// Detection is deliberately read-only. Ordinary commands must not turn a
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
// A legitimate mono install has no dws/multi product tree and is ignored.
func detectNestedMultiSkillLayout() (bool, error) {
home, err := skillSetupUserHomeDir()
if err != nil {
return false, err
}
for _, rel := range skillSetupAgentHomes {
nested := filepath.Join(home, rel, "dws", "multi")
if isSkillSourceRoot(nested, skillSetupModeMulti) {
return true, nil
}
}
return false, nil
}
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
if cmd == nil {
return true
}
if cmd.Name() == "upgrade" {
return false
}
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || !found {
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
}
}
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
mono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || found {
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
}
failure := errors.New("HOME failure")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
found, err = detectNestedMultiSkillLayout()
if found || !errors.Is(err, failure) {
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
}
}
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
upgradeCmd := &cobra.Command{Use: "upgrade"}
if shouldDetectNestedSkillLayout(upgradeCmd) {
t.Fatal("upgrade must manage its own Skill lifecycle")
}
skillCmd := &cobra.Command{Use: "skill"}
setupCmd := &cobra.Command{Use: "setup"}
skillCmd.AddCommand(setupCmd)
if shouldDetectNestedSkillLayout(setupCmd) {
t.Fatal("skill setup must manage its own Skill lifecycle")
}
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
t.Fatal("ordinary commands must trigger read-only detection")
}
}
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
// Any accidental return to startup mutation must fail this test before it
// can touch the fixture.
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
t.Fatal("ordinary command attempted to copy Skills")
return nil
})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
t.Fatal("ordinary command attempted to back up or remove Skills")
return "", nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
t.Fatal("ordinary command attempted to write Skill state")
return nil
})
root := newRootCommandWithEngine(context.Background(), nil, false, true)
cmd := &cobra.Command{Use: "version"}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := root.PersistentPreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
warning := stderr.String()
if !strings.Contains(warning, "dws skill setup --mode multi") {
t.Fatalf("safe migration hint missing: %q", warning)
}
if strings.Contains(warning, "--yes") {
t.Fatalf("migration hint bypasses confirmation: %q", warning)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
}
}
+4
View File
@@ -83,6 +83,10 @@ func TestMain(m *testing.M) {
// remove a TempDir while the audit lock is still open.
setupAuditSink()
openBrowserFunc = func(string) error { return nil }
// App tests may run in filtered CI processes. Install the same lazy Schema
// source factory as NewRootCommand without constructing a root so ResolveMeta
// tests never depend on an earlier test having initialized process state.
registerSchemaRuntimeDelivery()
code := m.Run()
StopAllStdioClients()
CloseAuditSink()
+9 -5
View File
@@ -57,9 +57,9 @@ var (
downloadUpgradeProgress = upgrade.DownloadWithProgress
extractUpgradeZip = upgrade.ExtractZip
findExtractedBinary = upgrade.FindBinaryInDir
locateUpgradeSkill = upgrade.LocateSkillMD
locateUpgradeSkill = upgrade.LocateSkillsRoot
replaceUpgradeSelf = upgrade.ReplaceSelf
installUpgradeSkills = upgrade.UpgradeSkillLocations
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
upgradeMkdirTemp = os.MkdirTemp
upgradeRemoveAll = os.RemoveAll
upgradeReadFile = os.ReadFile
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
Long: `检查并升级 DWS CLI 到最新版本。
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
升级前会自动备份当前版本,可通过 --rollback 回滚。
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出最近版本
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定正式版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --force # 即使已是最新版本也重装当前版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
return cmd
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
if hasSkills {
result, installErr := installUpgradeSkills(skillSrc)
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
Version: release.Version,
})
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
+1 -1
View File
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
}
return nil
}
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
@@ -0,0 +1,156 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
// layout then calling UpgradeSkillLocations with a multi bundle must install
// product skills, remove dws/, and leave non-DWS dirs alone.
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
extract := t.TempDir()
multiRoot := filepath.Join(extract, "multi")
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
dir := filepath.Join(multiRoot, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
result, err := upgrade.UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("multi skill missing: %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
}
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
// the repository's actual multi bundle (skills/multi, post-#887 layout with
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
// rename and stale releases leave behind: a pre-rename dws-shared directory
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
// ~/.dws/skills/multi cache refreshed.
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
bundle := filepath.Join("..", "..", "skills", "multi")
entries, err := os.ReadDir(bundle)
if err != nil {
t.Fatalf("real multi bundle missing: %v", err)
}
var bundleNames []string
for _, e := range entries {
if e.IsDir() {
bundleNames = append(bundleNames, e.Name())
}
}
if len(bundleNames) == 0 {
t.Fatal("real multi bundle is empty")
}
agentsBase := filepath.Join(home, ".agents", "skills")
// Mono install plus pre-rename shared and a stale product skill.
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
dir := filepath.Join(agentsBase, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
t.Fatal(err)
}
custom := filepath.Join(agentsBase, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
result, err := upgrade.UpgradeSkillLocations(bundle)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
}
}
for _, name := range bundleNames {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
}
// Shared skill must come from the renamed bundle dir, never the legacy name.
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
t.Errorf("dingtalk-shared missing: %v", err)
}
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
// check a bundle skill inside the cache instead.
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
}
}
}
+21
View File
@@ -18,6 +18,27 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
cmd := newUpgradeCommand()
force := cmd.Flags().Lookup("force")
if force == nil {
t.Fatal("upgrade --force flag is missing")
}
for _, text := range []string{cmd.Example, force.Usage} {
if strings.Contains(text, "恢复全部官方 Skill") {
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
}
}
if !strings.Contains(force.Usage, "已是最新版本") {
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
}
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
if !strings.Contains(cmd.Long, want) {
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
}
}
}
// --- ensureV ---
func TestEnsureV(t *testing.T) {
@@ -0,0 +1,80 @@
// 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 auth
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
type agentMetadataHeaderRoundTripFunc func(*http.Request) (*http.Response, error)
func (f agentMetadataHeaderRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestCrossPlatformCoverageOAuthRequestsExcludeAgentMetadataHeaders(t *testing.T) {
t.Setenv("DWS_AGENT_VER", "1.2.3-test")
t.Setenv("DWS_AGENT_EXT", `{"umt":"test-umt","miniwua":"test-wua","ua":"test-agent"}`)
assertExcluded := func(t *testing.T, header http.Header) {
t.Helper()
for _, name := range []string{"x-dws-agent-ver", "x-dws-agent-ext"} {
if values := header.Values(name); len(values) != 0 {
t.Fatalf("OAuth request header %q = %q, want absent", name, values)
}
}
}
newCaptureClient := func(responseBody string) (*http.Client, <-chan http.Header) {
seen := make(chan http.Header, 1)
client := &http.Client{Transport: agentMetadataHeaderRoundTripFunc(func(req *http.Request) (*http.Response, error) {
seen <- req.Header.Clone()
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
Request: req,
}, nil
})}
return client, seen
}
t.Run("JSON token request", func(t *testing.T) {
client, seen := newCaptureClient(`{}`)
provider := &OAuthProvider{httpClient: client}
if _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{
"code": "test-code",
"grantType": "authorization_code",
}); err != nil {
t.Fatalf("postJSON() error = %v", err)
}
assertExcluded(t, <-seen)
})
t.Run("device code request", func(t *testing.T) {
client, seen := newCaptureClient(`{"success":true,"result":{"deviceCode":"test-device-code","userCode":"TEST-CODE","verificationUri":"https://example.test/device","expiresIn":900,"interval":1}}`)
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.clientID = "test-client-id"
provider.httpClient = client
provider.SetBaseURL("https://oauth.test")
if _, err := provider.requestDeviceCode(context.Background()); err != nil {
t.Fatalf("requestDeviceCode() error = %v", err)
}
assertExcluded(t, <-seen)
})
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -515,3 +515,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
}
}
}
func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) {
docCommands := []string{
"doc +access-change",
"doc +access-grant",
"doc +access-revoke",
"doc +copy",
"doc +create",
"doc +create-from-template",
"doc +grant-and-share",
"doc +import",
"doc +list",
"doc +move",
"doc create",
"doc file create",
"doc import",
"doc template apply",
}
for _, command := range docCommands {
entry, ok := LookupParamAlias(command)
if !ok {
t.Errorf("published Doc command %q has no generated parameter-alias entry", command)
continue
}
for _, emitted := range []string{"space", "space-id"} {
target, active := entry.ResolveAlias(emitted)
if !active || target != "workspace" {
t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target)
}
if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) {
t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted)
}
}
}
driveInfo, ok := LookupParamAlias("drive info")
if !ok {
t.Fatal("published drive info command has no generated parameter-alias entry")
}
for _, emitted := range []string{"space", "workspace", "workspace-id"} {
target, active := driveInfo.ResolveAlias(emitted)
if !active || target != "space-id" {
t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target)
}
if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) {
t.Errorf("drive info --%s remains protected after restoring its published alias", emitted)
}
}
// Compatibility remains exact-path scoped. Strong type spellings introduced
// after the split continue to guard the two value domains elsewhere.
for _, test := range []struct {
command string
emitted string
}{
{command: "doc create", emitted: "storage-space-id"},
{command: "drive +upload", emitted: "workspace-id"},
{command: "drive info", emitted: "knowledge-base-id"},
} {
entry, ok := LookupParamAlias(test.command)
if !ok || !entry.IsBlocked(test.emitted) {
t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted)
}
}
}
+249 -30
View File
@@ -10,10 +10,10 @@
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
@@ -31,20 +31,31 @@
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
"drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
"doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"},
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"},
"drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"},
"drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
@@ -54,8 +65,8 @@
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
@@ -101,9 +112,9 @@
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
@@ -114,7 +125,7 @@
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
"doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
@@ -158,21 +169,86 @@
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
"doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."},
"doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."},
"doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."},
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."},
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
"doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."},
"drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]},
"drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]},
"drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]},
"drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]},
"drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
"drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]},
"drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]},
"drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
"drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
"drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."},
"drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
"drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."},
"drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]},
"drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."},
"drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."},
"drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
"drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."},
"drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."},
"drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
"drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."},
"drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."},
"drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."},
"drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."},
"drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."},
"drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."},
"drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
"drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
"drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
"drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
"drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
"drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."},
"drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."},
"drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."},
"drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."},
"drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."},
"drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}},
"drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
"drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
"drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."},
"drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}
},
"validation_fixture": {
@@ -243,7 +319,7 @@
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2},
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
@@ -325,7 +401,7 @@
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
@@ -415,9 +491,9 @@
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5},
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
@@ -425,11 +501,154 @@
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
{"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
{"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"},
{"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
{"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"},
{"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"},
{"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
{"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"},
{"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"},
{"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
{"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"},
{"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"},
{"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"},
{"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
{"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
{"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"},
{"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
{"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
{"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1},
{"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1},
{"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
{"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
{"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"},
{"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"},
{"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
{"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6},
{"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1},
{"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
{"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
{"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
{"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
{"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3},
{"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1},
{"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1},
{"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3},
{"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1},
{"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2},
{"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2},
{"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1},
{"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
{"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
{"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1},
{"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2},
{"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1},
{"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1},
{"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"},
{"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1},
{"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1},
{"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1},
{"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
{"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
{"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1},
{"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
{"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
{"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"},
{"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"},
{"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"},
{"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"},
{"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
{"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"},
{"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"},
{"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"},
{"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"},
{"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
{"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"},
{"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
{"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"},
{"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"},
{"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"},
{"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"},
{"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
{"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
{"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"},
{"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"},
{"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"},
{"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"},
{"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"},
{"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
{"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"},
{"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
{"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
{"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}
]
}
}
+11
View File
@@ -1356,6 +1356,9 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message send", "idempotency-key", "uuid", false, ""},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
@@ -1405,6 +1408,14 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden)
}
}
sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"})
if err != nil {
t.Fatal(err)
}
sendParams = schemaMap(sendLeaf["parameters"])
if _, ok := sendParams["uuid"]; ok {
t.Fatal("chat message send unexpectedly publishes hidden alias --uuid")
}
listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"})
if err != nil {
t.Fatal(err)
+169 -64
View File
@@ -18,6 +18,7 @@ import (
"unicode/utf8"
"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/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
@@ -467,22 +468,50 @@ func validateNativeSearchConversationScope(conversationIDs []string) error {
}
func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour)
if err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
if err := validateTimeRange(startMs, endMs); err != nil {
return nil, err
}
cursor, _ := cmd.Flags().GetString("cursor")
return map[string]any{
"startTime": mustGetFlag(cmd, "start"),
"endTime": mustGetFlag(cmd, "end"),
"startTime": startRaw,
"endTime": endRaw,
"limit": chatIntFlagOrFallback(cmd, "limit", "size"),
"cursor": cursor,
}, nil
}
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start"); err != nil {
return nil, err
func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
startRaw := mustGetFlag(cmd, "start")
endRaw := mustGetFlag(cmd, "end")
anchor := time.Now()
if endRaw == "" {
endRaw = formatChatMessageListAllTime(anchor.UnixMilli())
} else if startRaw == "" {
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return "", "", err
}
anchor = time.UnixMilli(endMs)
}
if startRaw == "" {
startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli())
}
return startRaw, endRaw, nil
}
func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
senderUserID := flagOrFallback(cmd, "sender-user-id", "sender")
senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id")
if senderUserID != "" && senderOpenDingTalkID != "" {
@@ -491,13 +520,18 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if senderUserID == "" && senderOpenDingTalkID == "" {
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if senderOpenDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
return nil, err
}
}
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endRaw, _ := cmd.Flags().GetString("end")
if strings.TrimSpace(endRaw) == "" {
endRaw = time.Now().Format(time.RFC3339)
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
@@ -522,14 +556,15 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
}
func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
@@ -564,14 +599,15 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil {
return nil, err
}
if err := validateRequiredFlags(cmd, "start", "end"); err != nil {
return nil, err
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end"))
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
return nil, err
}
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return nil, err
}
@@ -592,6 +628,33 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) {
return toolArgs, nil
}
func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) {
startRaw := mustGetFlag(cmd, "start")
endRaw := mustGetFlag(cmd, "end")
anchor := time.Now()
if endRaw == "" {
endRaw = anchor.Format(time.RFC3339)
} else if startRaw == "" {
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
return "", "", err
}
anchor = time.UnixMilli(endMs)
}
if startRaw == "" {
startRaw = anchor.Add(-lookback).Format(time.RFC3339)
}
return startRaw, endRaw, nil
}
func formatChatMessageListAllTime(ms int64) string {
return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05")
}
func defaultChatMessageListTime() string {
return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05")
}
func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) {
toolArgs := map[string]any{}
if v := flagOrFallback(cmd, "query", "keyword"); v != "" {
@@ -993,8 +1056,7 @@ func isNumericUserID(value string) bool {
}
func isOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
}
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
@@ -2605,8 +2667,9 @@ func newChatCommand() *cobra.Command {
chatMessageListCmd := &cobra.Command{
Use: "list",
Short: "拉取会话消息内容",
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Example: ` dws chat message list --group <openconversation_id>
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
@@ -2614,9 +2677,6 @@ func newChatCommand() *cobra.Command {
# 查询群 ID: dws chat search --query "群名"
# 查询 userId: dws contact user search --query "姓名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "time"); err != nil {
return err
}
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
@@ -2636,15 +2696,25 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
}
forward, err := resolveMessageForward(cmd, true)
timeVal := mustGetFlag(cmd, "time")
defaultForward := true
if timeVal == "" {
timeVal = defaultChatMessageListTime()
defaultForward = false
}
forward, err := resolveMessageForward(cmd, defaultForward)
if err != nil {
return err
}
timeVal := mustGetFlag(cmd, "time")
if groupID != "" {
toolArgs := map[string]any{
"openconversation_id": groupID,
@@ -2695,7 +2765,7 @@ func newChatCommand() *cobra.Command {
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
Examples: []string{
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
"dws chat message list --group <openConversationId> --limit 50",
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
},
},
@@ -2723,6 +2793,11 @@ func newChatCommand() *cobra.Command {
if userID == "" && openDingTalkID == "" {
return fmt.Errorf("--user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2730,7 +2805,7 @@ func newChatCommand() *cobra.Command {
timeVal, _ := cmd.Flags().GetString("time")
defaultForward := true
if strings.TrimSpace(timeVal) == "" {
timeVal = time.Now().Format("2006-01-02 15:04:05")
timeVal = defaultChatMessageListTime()
defaultForward = false
}
forward, err := resolveMessageForward(cmd, defaultForward)
@@ -2799,6 +2874,8 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
返回值与后续操作:
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
@@ -2816,6 +2893,8 @@ func newChatCommand() *cobra.Command {
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --group <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --group <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --group <openconversation_id> --msg-type file --file-path ./report.pdf
@@ -2831,7 +2910,7 @@ func newChatCommand() *cobra.Command {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
msgUuid, _ := cmd.Flags().GetString("uuid")
msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid")
specified := 0
if groupID != "" {
specified++
@@ -2848,6 +2927,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -3046,6 +3130,7 @@ func newChatCommand() *cobra.Command {
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
{Name: "group", Property: "openConversationId"},
{Name: "idempotency-key", Property: "uuid"},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"},
},
},
@@ -3483,7 +3568,7 @@ func newChatCommand() *cobra.Command {
chatMessageListAllCmd := &cobra.Command{
Use: "list-all",
Short: "拉取指定时间范围内当前用户的所有会话消息",
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`,
Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`,
@@ -3530,7 +3615,7 @@ func newChatCommand() *cobra.Command {
chatMessageListBySenderCmd := &cobra.Command{
Use: "list-by-sender",
Short: "拉取指定发送者的消息(包含单聊和群聊)",
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0
@@ -3582,7 +3667,7 @@ func newChatCommand() *cobra.Command {
chatMessageListMentionsCmd := &cobra.Command{
Use: "list-mentions",
Short: "拉取 @我 的消息",
Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
@@ -3770,7 +3855,7 @@ func newChatCommand() *cobra.Command {
chatMessageSearchCmd := &cobra.Command{
Use: "search",
Short: "按关键词搜索消息",
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
@@ -4328,9 +4413,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)")
chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)")
chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)")
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐)")
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older")
chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)")
chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)")
_ = chatMessageListCmd.Flags().MarkHidden("forward")
chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制")
chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名")
@@ -4381,7 +4466,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
_ = chatMessageSendCmd.Flags().MarkHidden("file-type")
_ = chatMessageSendCmd.Flags().MarkHidden("file-size")
chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageSendCmd.Flags().String("uuid", "", "幂等 UUID,相同 uuid 在 24h 内不会重复发送(可选)")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "idempotency-key",
Usage: "幂等键,相同 key 在 24h 内不会重复发送(可选)",
Aliases: []string{"uuid"},
}})
cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat")
cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}},
@@ -4444,10 +4533,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)")
_ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward")
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
_ = chatMessageListAllCmd.MarkFlagRequired("start")
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)")
_ = chatMessageListAllCmd.MarkFlagRequired("end")
chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)")
chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)")
chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListAllCmd.Flags().MarkHidden("size")
@@ -4461,9 +4548,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)")
chatMessageListBySenderCmd.Flags().String("user", "", "")
_ = chatMessageListBySenderCmd.Flags().MarkHidden("user")
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageListBySenderCmd.MarkFlagRequired("start")
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListBySenderCmd.Flags().MarkHidden("size")
@@ -4472,10 +4558,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
// list-mentions flags
chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)")
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageListMentionsCmd.MarkFlagRequired("start")
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
_ = chatMessageListMentionsCmd.MarkFlagRequired("end")
chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)")
chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageListMentionsCmd.Flags().MarkHidden("size")
@@ -4500,10 +4584,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatMessageSearchCmd.Flags().MarkHidden("keyword")
chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)")
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)")
_ = chatMessageSearchCmd.MarkFlagRequired("start")
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)")
_ = chatMessageSearchCmd.MarkFlagRequired("end")
chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)")
chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)")
chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)")
chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatMessageSearchCmd.Flags().MarkHidden("size")
@@ -4663,9 +4745,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
userID := rawUserID
openDingTalkID := rawOpenDingTalkID
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
userID = openDingTalkID
openDingTalkID = ""
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
@@ -5721,27 +5804,41 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
Use: "send-card",
Short: "创建并推送流式卡片",
Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。
群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
创建时无需传入卡片内容,后续通过 update-card 更新内容。
注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容,
最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`,
Example: ` dws chat message send-card --group <openConversationId>
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
dws chat message send-card --group <openConversationId> --at-all
dws chat message send-card --receiver <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
receiver, _ := cmd.Flags().GetString("receiver")
atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")))
atAll, _ := cmd.Flags().GetBool("at-all")
if groupID == "" && receiver == "" {
return fmt.Errorf("--group or --receiver is required")
}
if groupID != "" && receiver != "" {
return fmt.Errorf("--group and --receiver are mutually exclusive")
}
if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) {
return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group")
}
toolArgs := map[string]any{}
if groupID != "" {
toolArgs["openConversationId"] = groupID
if len(atOpenDingTalkIDs) > 0 {
toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs
}
if atAll {
toolArgs["atAll"] = true
}
}
if receiver != "" {
resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver)
@@ -5766,19 +5863,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
CLIPath: "chat message send-card",
PrimaryCLIPath: "chat message send-card",
},
Description: "创建并向群聊或单聊发送互动卡片",
Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建并向群聊或单聊发送互动卡片",
UseWhen: []string{"需要卡片式交互且已准备接收会话或用户时"},
AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"},
AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"},
Examples: []string{"dws chat message send-card --group <openConversationId>"},
},
Parameters: []contract.ParamDecl{
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "group", Property: "openConversationId"},
{Name: "receiver", Property: "receiverOpenDingTalkId"},
},
@@ -5786,6 +5885,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
})
chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)")
chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)")
chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)")
chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)")
chatMessageUpdateCardCmd := &cobra.Command{
Use: "update-card",
@@ -5895,6 +5996,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Short: "下载消息中的资源(图片/视频/语音等)到本地",
Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。
本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。
如需用 fileId 下载,请使用钉盘/drive 下载命令。
流程:
1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL
2. HTTP GET 下载文件到本地
@@ -5905,6 +6009,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./photo.jpg
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
# 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令
# message-id: 从 dws chat message list 返回的 openMessageId
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
PreRunE: func(cmd *cobra.Command, args []string) error {
@@ -6034,16 +6139,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Selection: contract.SelectionSpec{
AgentSummary: "下载消息中的媒体资源到本地",
UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"},
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令"},
AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"},
Examples: []string{"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."},
},
},
})
// download-media flags
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId (必填)")
chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("type")
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)")
chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id")
chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
+6 -4
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
t.Run("success acknowledgement is verified", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
@@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.tool != "update_streaming_card" {
t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool)
}
})
@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -159,6 +160,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &productExampleCaller{}
if got := formatChatMessageListAllTime(0); got == "" {
t.Fatal("formatChatMessageListAllTime returned empty string")
}
commands := [][]string{
{"chmod", "chat.read", "--ttl="},
@@ -167,9 +171,17 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"},
{"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"},
{"message", "list-direct", "--user=D-user", "--limit=1"},
{"message", "list-all"},
{"message", "list-all", "--end=2026-01-02T00:00:00Z"},
{"message", "list-all", "--end=bad"},
{"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"},
{"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"},
{"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"},
{"message", "list-all", "--start=2027-01-01 00:00:00"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"},
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"},
{"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"},
{"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"},
@@ -272,6 +284,52 @@ func TestCrossPlatformCoverageChatCreateAndMessageSendEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
t.Run("group forwards mention arguments", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--group=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"atOpenDingTalkIds": []string{"D1", "D2"},
"atAll": true,
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
for _, tc := range []struct {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
t.Fatalf("invalid direct-message mentions made %d tool calls", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
+25 -4
View File
@@ -14,6 +14,11 @@ import (
"github.com/spf13/cobra"
)
const (
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func newChatFlagTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "chat"}
cmd.Flags().String("forward", "", "")
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
return cmd
}
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
userIDs, openIDs := splitChatIDValues([]string{
helperCurrentDOpenID,
"D-prefix-fixture-user",
"d-prefix-fixture-user",
"D-invalid",
"fixture-user-id",
})
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
t.Fatalf("open IDs=%#v", openIDs)
}
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
t.Fatalf("user IDs=%#v", userIDs)
}
}
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
search := &cobra.Command{Use: "search"}
search.Flags().String("nicks", "", "")
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
t.Fatalf("direct ID = %q, %v", id, err)
}
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
t.Fatal("empty ID unexpectedly resolved")
}
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
t.Fatalf("direct IDs = %#v, %v", ids, err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
installScriptedCaller(t, caller)
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
t.Fatalf("resolved IDs = %#v, %v", ids, err)
}
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
+4 -4
View File
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--open-dingtalk-id=D-target",
"--open-dingtalk-id=" + helperCurrentDOpenID,
"--msg-type=file",
"--file-path=" + filePath,
}
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"uploadKey": "upload-key",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send direct target call = %#v", send)
}
if send.args["receiverOpenDingTalkId"] != "D-target" {
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
t.Fatalf("send direct target = %#v", send.args)
}
if _, ok := send.args["openDingTalkId"]; ok {
@@ -90,3 +90,96 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
})
}
}
func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) {
tests := []struct {
name string
args []string
contains []string
absent []string
}{
{
name: "list",
args: []string{"message", "list", "--help"},
contains: []string{
"--time 可选,不传时默认上海时间当前时间并向旧消息拉取",
"默认上海时间当前时间",
"未传 --time 时默认 older",
},
absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
},
{
name: "search",
args: []string{"message", "search", "--help"},
contains: []string{
"可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-by-sender",
args: []string{"message", "list-by-sender", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-mentions",
args: []string{"message", "list-mentions", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
"默认当前时间前 7 天",
"默认当前时间",
},
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
},
{
name: "list-all",
args: []string{"message", "list-all", "--help"},
contains: []string{
"--start 和 --end 可选,不传时默认最近 1 天到当前时间",
"默认当前时间前 1 天",
"默认当前时间",
},
absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
},
{
name: "download-media",
args: []string{"message", "download-media", "--help"},
contains: []string{
"只支持聊天消息 mediaId 下载,不支持钉盘 fileId",
"fileId 下载请使用钉盘/drive 下载命令",
"仅支持聊天消息 mediaId,不支持钉盘 fileId",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String())
}
help := output.String()
for _, want := range test.contains {
if !strings.Contains(help, want) {
t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help)
}
}
for _, unwanted := range test.absent {
if strings.Contains(help, unwanted) {
t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help)
}
}
})
}
}
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"time"
@@ -148,6 +149,356 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
}
}
func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) {
tests := []struct {
name string
args []string
wantTool string
wantLookback time.Duration
}{
{
name: "list-all defaults to one day",
args: []string{"message", "list-all"},
wantTool: "search_messages_by_time_range",
wantLookback: 24 * time.Hour,
},
{
name: "list-by-sender defaults to seven days",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1"},
wantTool: "search_messages_by_sender",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "list-mentions defaults to seven days",
args: []string{"message", "list-mentions"},
wantTool: "search_at_me_message",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "search defaults to seven days",
args: []string{"message", "search", "--query", "发布"},
wantTool: "search_messages_by_keyword",
wantLookback: 7 * 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
before := time.Now()
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
wantEndMin := before.Truncate(time.Second).UnixMilli()
wantEndMax := after.Add(time.Second).UnixMilli()
if endMs < wantEndMin || endMs > wantEndMax {
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
}
wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli()
wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli()
if startMs < wantStartMin || startMs > wantStartMax {
t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax)
}
if tt.wantTool == "search_messages_by_time_range" {
assertChatMessageListAllTimeFormat(t, got.args["startTime"])
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
}
})
}
}
func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) {
endRaw := "2026-01-01T00:00:00+08:00"
endMs, err := parseISOTimeToMillis("end", endRaw)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
wantTool string
wantLookback time.Duration
}{
{
name: "list-all defaults start one day before explicit end",
args: []string{"message", "list-all", "--end", endRaw},
wantTool: "search_messages_by_time_range",
wantLookback: 24 * time.Hour,
},
{
name: "list-by-sender defaults start seven days before explicit end",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw},
wantTool: "search_messages_by_sender",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "list-mentions defaults start seven days before explicit end",
args: []string{"message", "list-mentions", "--end", endRaw},
wantTool: "search_at_me_message",
wantLookback: 7 * 24 * time.Hour,
},
{
name: "search defaults start seven days before explicit end",
args: []string{"message", "search", "--query", "发布", "--end", endRaw},
wantTool: "search_messages_by_keyword",
wantLookback: 7 * 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
if gotEndMs != endMs {
t.Fatalf("endTime = %d, want %d", gotEndMs, endMs)
}
wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli()
if startMs != wantStartMs {
t.Fatalf("startTime = %d, want %d", startMs, wantStartMs)
}
if tt.wantTool == "search_messages_by_time_range" {
assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs))
assertStringArg(t, got.args["endTime"], endRaw)
}
})
}
}
func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) {
previousLocal := time.Local
t.Cleanup(func() { time.Local = previousLocal })
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
t.Run(loc.String(), func(t *testing.T) {
time.Local = loc
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00")
assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00")
startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string))
if err != nil {
t.Fatal(err)
}
endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string))
if err != nil {
t.Fatal(err)
}
if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour {
t.Fatalf("window = %v, want 24h", gotWindow)
}
})
}
}
func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) {
startRaw := "2026-01-01T00:00:00+08:00"
startMs, err := parseISOTimeToMillis("start", startRaw)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
wantTool string
}{
{
name: "list-all defaults end to now",
args: []string{"message", "list-all", "--start", startRaw},
wantTool: "search_messages_by_time_range",
},
{
name: "list-by-sender defaults end to now",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw},
wantTool: "search_messages_by_sender",
},
{
name: "list-mentions defaults end to now",
args: []string{"message", "list-mentions", "--start", startRaw},
wantTool: "search_at_me_message",
},
{
name: "search defaults end to now",
args: []string{"message", "search", "--query", "发布", "--start", startRaw},
wantTool: "search_messages_by_keyword",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
before := time.Now()
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one call", caller.calls)
}
got := caller.calls[0]
if got.tool != tt.wantTool {
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
}
gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
if gotStartMs != startMs {
t.Fatalf("startTime = %d, want %d", gotStartMs, startMs)
}
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
wantEndMin := before.Truncate(time.Second).UnixMilli()
wantEndMax := after.Add(time.Second).UnixMilli()
if endMs < wantEndMin || endMs > wantEndMax {
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
}
if tt.wantTool == "search_messages_by_time_range" {
assertStringArg(t, got.args["startTime"], startRaw)
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
}
})
}
}
func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "invalid start",
args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"},
wantErr: "cannot parse time for --start",
},
{
name: "end before start",
args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"},
wantErr: "--end must be after --start",
},
{
name: "default end before future start",
args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"},
wantErr: "--end must be after --start",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want no MCP call", caller.calls)
}
})
}
}
func numericArgAsInt64(t *testing.T, value any) int64 {
t.Helper()
switch v := value.(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
case string:
parsed, err := parseISOTimeToMillis("time", v)
if err != nil {
t.Fatalf("parse time arg %q: %v", v, err)
}
return parsed
default:
t.Fatalf("unsupported numeric arg type %T (%#v)", value, value)
return 0
}
}
func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 {
t.Helper()
if tool == "search_messages_by_time_range" {
return parseChatMessageListAllTimeArg(t, value).UnixMilli()
}
return numericArgAsInt64(t, value)
}
func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time {
t.Helper()
raw, ok := value.(string)
if !ok {
t.Fatalf("time arg = %#v, want time string", value)
}
if strings.Contains(raw, "T") {
parsedMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
return time.UnixMilli(parsedMs)
}
parsedMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
return time.UnixMilli(parsedMs)
}
func assertChatMessageListAllTimeFormat(t *testing.T, value any) {
t.Helper()
raw, ok := value.(string)
if !ok {
t.Fatalf("time arg = %#v, want time string", value)
}
if strings.Contains(raw, "T") {
t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw)
}
if _, err := parseISOTimeToMillis("time", raw); err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
}
func assertStringArg(t *testing.T, value any, want string) {
t.Helper()
got, ok := value.(string)
if !ok || got != want {
t.Fatalf("arg = %#v, want %q", value, want)
}
}
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
tests := []struct {
name string
+119 -26
View File
@@ -724,6 +724,99 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.
}
}
func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) {
previousLocal := time.Local
t.Cleanup(func() { time.Local = previousLocal })
tests := []struct {
name string
args []string
wantTool string
wantTarget map[string]any
}{
{
name: "group",
args: []string{"message", "list", "--group", "cid-1", "--limit", "50"},
wantTool: "list_conversation_message_v2",
wantTarget: map[string]any{"openconversation_id": "cid-1"},
},
{
name: "user",
args: []string{"message", "list", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "user open DingTalk ID fallback",
args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "open DingTalk ID",
args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "direct user",
args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "direct open DingTalk ID",
args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
}
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
t.Run(loc.String(), func(t *testing.T) {
time.Local = loc
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
before := time.Now()
err := executeChatChangedContract(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one MCP call", caller.calls)
}
if caller.calls[0].toolName != tt.wantTool {
t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool)
}
for key, want := range tt.wantTarget {
if got := caller.calls[0].args[key]; got != want {
t.Fatalf("arg %s = %#v, want %#v", key, got, want)
}
}
raw, ok := caller.calls[0].args["time"].(string)
if !ok || raw == "" {
t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"])
}
gotMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
wantMin := before.Add(-time.Second).UnixMilli()
wantMax := after.Add(time.Second).UnixMilli()
if gotMs < wantMin || gotMs > wantMax {
t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax)
}
if caller.calls[0].args["forward"] != false {
t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"])
}
})
}
})
}
}
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
@@ -786,11 +879,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
},
}
@@ -819,11 +912,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
}
@@ -857,25 +950,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply",
@@ -883,15 +976,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--ref-sender", helperCurrentDOpenID,
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "reply adds missing member placeholders",
@@ -899,13 +992,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
"--ref-sender", helperCurrentDOpenID,
"--text", "DWS synthetic reply mention test",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
},
{
name: "reply adds missing member placeholders after at-all",
@@ -913,15 +1006,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply at-all preserves alliance word",
@@ -929,7 +1022,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
"--at-all",
},
@@ -943,7 +1036,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
},
contentField: "content",
+214 -6
View File
@@ -492,10 +492,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return nil
}
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
// 2. HTTP PUT file content to OSS
// 3. insert_document_block with attachment element
// 4. list_document_blocks → prove the uploaded resource is visible in the document
func runMediaInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -551,7 +552,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -569,7 +570,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 2: HTTP PUT file to OSS
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -592,7 +593,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 3: insert block into document
deps.Out.PrintInfo("[3/3] 插入块到文档...")
deps.Out.PrintInfo("[3/4] 插入块到文档...")
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
@@ -644,7 +645,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
insertArgs["referenceBlockId"] = v
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
if err != nil {
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
@@ -665,6 +667,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
apperrors.WithCause(err),
)
}
insertResult := map[string]any{}
if strings.TrimSpace(insertText) != "" {
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
}
}
insertedBlockID := insertedDocBlockID(insertResult)
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
if verifyErr != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
}
if insertedBlockID == "" {
insertedBlockID = verifiedBlockID
}
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
@@ -674,16 +693,205 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
"inserted": true, "verified": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "success"},
},
})
}
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
return apperrors.NewAPI(
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_verification_failed"),
apperrors.WithFailureStage("verify"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "failed"},
},
}),
apperrors.WithCause(cause),
)
}
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
if err != nil {
lastErr = err
} else {
lastErr = nil
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
return found, nil
}
}
if attempt < len(delays) {
helperSleep(delays[attempt])
}
}
if lastErr != nil {
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
}
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
}
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
const pageSize = 50
const maxItems = 5000
all := make([]any, 0, pageSize)
seen := map[string]bool{}
for start := 0; start < maxItems; start += pageSize {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
}
payload = nestedDocMap(payload)
blocks, ok := payload["blocks"].([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
encoded, _ := json.Marshal(blocks)
key := string(encoded)
if key != "[]" && seen[key] {
return nil, fmt.Errorf("list_document_blocks 分页停滞")
}
seen[key] = true
all = append(all, blocks...)
hasMore, hasMoreKnown := payload["hasMore"].(bool)
if hasMoreKnown && !hasMore {
return all, nil
}
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
return all, nil
}
if !hasMoreKnown && len(blocks) < pageSize {
return all, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
}
}
return nil, fmt.Errorf("文档块超过安全回读上限")
}
func nestedDocMap(data map[string]any) map[string]any {
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
return nestedDocMap(nested)
}
}
return data
}
func nestedDocString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
case []any:
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
}
return ""
}
// insertedDocBlockID only accepts explicit block IDs from the insert result or
// known response wrappers. Arbitrary IDs may belong to the document, operator,
// or request and must not become a hard constraint for the media readback.
func insertedDocBlockID(data map[string]any) string {
for _, key := range []string{"blockId", "elementId"} {
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if text := insertedDocBlockID(inner); text != "" {
return text
}
}
}
return ""
}
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
for _, value := range blocks {
candidateID := nestedDocString(value, "blockId", "id", "uuid")
if candidateID == "" || (blockID != "" && candidateID != blockID) {
continue
}
mediaValue := docMediaReadbackValue(value)
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
return candidateID
}
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
return candidateID
}
}
return ""
}
func docMediaReadbackValue(value any) any {
block, ok := value.(map[string]any)
if !ok {
return value
}
encoded, ok := block["jsonml"].(string)
if !ok || strings.TrimSpace(encoded) == "" {
return value
}
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) != nil {
return value
}
return decoded
}
func docNumberAsInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed == float64(int(typed)) {
return int(typed), true
}
case int:
return typed, typed >= 0
}
return 0, false
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
var data map[string]any
+158 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +14,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -74,6 +76,7 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
oldPut, oldGet := httpPutFile, httpGetFile
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
testseam.Swap(t, &helperSleep, func(time.Duration) {})
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
@@ -193,6 +196,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Fatal("media insert failure returned nil")
}
})
t.Run("media insert response parse failure", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
{text: `{`},
}}
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
t.Fatal("invalid media insert response returned nil")
}
})
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"blockId":"media-block"}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
if caller.args["format"] != "jsonml" {
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
}
})
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
})
t.Run("large image becomes attachment", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
large := filepath.Join(t.TempDir(), "large.png")
@@ -202,7 +246,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
if err := os.Truncate(large, 21*1024*1024); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{}`},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
t.Fatal(err)
}
@@ -240,6 +288,115 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &helperSleep, func(time.Duration) {})
ctx := context.Background()
for _, tc := range []struct {
name string
steps []scriptedToolStep
}{
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
{"invalid json", []scriptedToolStep{{text: `{`}}},
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
} {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("defensive readback returned nil")
}
})
}
t.Run("total count and nested payload", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
blocks, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("unknown pagination short page", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("bounded retry failure", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
t.Fatal("missing media unexpectedly verified")
}
})
t.Run("transient read failure retries", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{err: errors.New("temporary read failure")},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
installScriptedCaller(t, caller)
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
if err != nil || blockID != "media-block" || caller.calls != 2 {
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
}
})
t.Run("block identity alone does not verify media", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
t.Fatal("matching block ID with a different resource unexpectedly verified")
}
})
t.Run("block read safety limit", func(t *testing.T) {
steps := make([]scriptedToolStep, 100)
for index := range steps {
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
}
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
t.Fatalf("nested map=%#v", got)
}
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
t.Fatal("nested string traversal failed")
}
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
t.Fatalf("trusted inserted block ID = %q, want block", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
}
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
t.Fatal("media block matching failed")
}
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
t.Fatal("invalid JSONML unexpectedly verified")
}
if got := docMediaReadbackValue("plain"); got != "plain" {
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
}
for _, tc := range []struct {
value any
want bool
}{
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
} {
_, ok := docNumberAsInt(tc.value)
if ok != tc.want {
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
}
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
t.Cleanup(func() {
+104 -4
View File
@@ -281,6 +281,85 @@ func parseDriveUploadInfo(text string) (resourceURL, uploadID string, headers ma
return
}
// DriveUploadRequest describes the reusable Drive upload transaction used by
// the native leaf and the curated +upload shortcut. FilePath must already be
// resolved and validated by the caller.
type DriveUploadRequest struct {
FilePath string
FileName string
FileSize int64
SpaceID string
ParentID string
OverwriteFile string
MIMEType string
}
// UploadDriveFileData runs credentials -> OSS PUT -> commit exactly once and
// returns the parsed commit response without rendering it. Unlike the legacy
// leaf helper, this path fails when the commit has no non-empty JSON response;
// the Shortcut can then require terminal success evidence and read back the
// created node before reporting success.
func UploadDriveFileData(ctx context.Context, request DriveUploadRequest) (map[string]any, error) {
if strings.TrimSpace(request.FilePath) == "" || strings.TrimSpace(request.FileName) == "" || request.FileSize <= 0 {
return nil, fmt.Errorf("invalid drive upload request")
}
step1Args := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
}
if request.SpaceID != "" {
step1Args["spaceId"] = request.SpaceID
}
if request.MIMEType != "" {
step1Args["mimeType"] = request.MIMEType
}
if request.OverwriteFile != "" {
step1Args["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
step1Args["parentId"] = request.ParentID
}
credentialText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
if err != nil {
return nil, err
}
uploadID, err := driveUploadPut(ctx, credentialText, func(refreshCtx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(refreshCtx, "drive", "get_upload_info", step1Args)
}, request.FilePath, request.FileSize)
if err != nil {
return nil, err
}
commitArgs := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
"uploadId": uploadID,
}
if request.SpaceID != "" {
commitArgs["spaceId"] = request.SpaceID
}
if request.OverwriteFile != "" {
commitArgs["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
commitArgs["parentId"] = request.ParentID
}
commitText, err := callMCPToolReturnTextOnServer(ctx, "drive", "commit_upload", commitArgs)
if err != nil {
return nil, err
}
if strings.TrimSpace(commitText) == "" {
return nil, fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
}
var result map[string]any
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
return nil, fmt.Errorf("parse commit_upload response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
}
return result, nil
}
func newDriveCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/drive.json
// products.drive). Catalog assembly stamps provenance contract_final.
@@ -334,6 +413,13 @@ func newDriveCommand() *cobra.Command {
}
}
// --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一);
// 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。
filter, err := parseDriveListFilter(cmd)
if err != nil {
return err
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -375,7 +461,8 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 || latest > 0 {
// filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。
if depth > 1 || latest > 0 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -384,7 +471,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
if pattern != "" {
return &CLIError{
@@ -408,7 +495,9 @@ func newDriveCommand() *cobra.Command {
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
}
if depth > 1 {
// 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛);
// filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。
if depth > 1 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
@@ -429,7 +518,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
// 默认路由:钉盘文件列表
@@ -484,6 +573,11 @@ func newDriveCommand() *cobra.Command {
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
argsMap["withThumbnail"] = true
}
// --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛);
// 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。
if pattern != "" {
return callDriveListPageWithPattern(argsMap, pattern)
}
return callMCPTool("list_files", argsMap)
},
}
@@ -512,11 +606,13 @@ func newDriveCommand() *cobra.Command {
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
"要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)",
},
AvoidWhen: []string{
"只记得关键词、不知道所在目录时改用 dws drive search",
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
"要找最近打开/编辑过的文档改用 dws drive recent",
"带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)",
},
Examples: []string{
"dws drive list --limit 20 --format json",
@@ -1132,6 +1228,9 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -3204,6 +3303,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
newDriveFileCommentCmd(),
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
@@ -154,6 +154,88 @@ func TestCrossPlatformCoverageDriveUploadTransportCoverage(t *testing.T) {
})
}
func TestCrossPlatformCoverageUploadDriveFileDataStrictTransaction(t *testing.T) {
credential := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=u1","uploadId":"u1","headers":{"dentry-token":"token"}}}`
request := DriveUploadRequest{FilePath: "fixture.bin", FileName: "fixture.bin", FileSize: 7, SpaceID: "space", ParentID: "folder", MIMEType: "application/octet-stream"}
t.Run("success with parent", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true,"result":{"fileId":"n1"}}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
result, err := UploadDriveFileData(context.Background(), request)
if err != nil || result["success"] != true || caller.calls != 2 {
t.Fatalf("result=%v calls=%d error=%v", result, caller.calls, err)
}
if caller.args["spaceId"] != "space" || caller.args["parentId"] != "folder" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("success with overwrite", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
overwrite := request
overwrite.ParentID = "ignored"
overwrite.OverwriteFile = "existing"
if _, err := UploadDriveFileData(context.Background(), overwrite); err != nil {
t.Fatal(err)
}
if caller.args["overwriteFileId"] != "existing" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("credential refresh callback", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
putCalls := 0
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
putCalls++
if putCalls == 1 {
return &httpStatusError{StatusCode: 401, Body: "expired"}
}
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
if _, err := UploadDriveFileData(context.Background(), request); err != nil {
t.Fatal(err)
}
if caller.calls != 3 || putCalls != 2 {
t.Fatalf("caller calls=%d put calls=%d", caller.calls, putCalls)
}
})
for _, tc := range []struct {
name string
request DriveUploadRequest
steps []scriptedToolStep
putErr error
want string
}{
{name: "invalid request", request: DriveUploadRequest{}, want: "invalid drive upload request"},
{name: "credential failure", request: request, steps: []scriptedToolStep{{err: errors.New("credentials")}}, want: "credentials"},
{name: "put failure", request: request, steps: []scriptedToolStep{{text: credential}}, putErr: errors.New("put failed"), want: "put failed"},
{name: "commit failure", request: request, steps: []scriptedToolStep{{text: credential}, {err: errors.New("commit failed")}}, want: "commit failed"},
{name: "empty commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: " "}}, want: "no business result"},
{name: "malformed commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: "{"}}, want: "parse commit_upload response"},
{name: "empty object commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: `{}`}}, want: "empty JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: tc.steps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
t.Cleanup(func() { SetHTTPPutFile(nil) })
_, err := UploadDriveFileData(context.Background(), tc.request)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error=%v, want %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageDriveCommandRemainingEdges(t *testing.T) {
file := filepath.Join(t.TempDir(), "fixture.txt")
_ = os.WriteFile(file, []byte("fixture"), 0o600)
+17 -11
View File
@@ -178,7 +178,8 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +210,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +221,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +234,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
}
if err != nil {
folderErr = err
@@ -297,7 +298,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
return emitErr
}
return folderErr
@@ -338,18 +339,18 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -364,8 +365,13 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
if filter.active() {
// 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛
items = applyDriveListFilter(items, route, filter)
}
if latest > 0 {
items = applyDriveListLatest(items, latest)
// --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。
items = applyDriveListLatest(items, latest, filter.nodeType == "folder")
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
@@ -383,7 +389,7 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
if (latest > 0 || filter.active()) && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
+17 -17
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0, driveListFilter{})
return decodeDepthResult(t, out), err
}
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil {
t.Fatal("root failure returned nil")
}
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil {
t.Fatal("unrecoverable returned nil")
}
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+3 -2
View File
@@ -49,10 +49,11 @@ func driveLatestExclusiveError(flag string, latest int) error {
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
// foldersOnly=true 时对目录取 Top-N(--type folder --latest 组合),否则对文件取 Top-N。
func applyDriveListLatest(items []map[string]any, latest int, foldersOnly bool) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
if (isDriveDepthFolder(item) || isDocDepthFolder(item)) != foldersOnly {
continue
}
files = append(files, item)
+232
View File
@@ -0,0 +1,232 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// drive list --type/--start/--end 客户端过滤(CLI 侧筛)
//
// 两路由统一(拍板⑤):钉盘与知识库均为全量拉取后在进程内筛——
// 复用 depth/latest 的 BFS 基建(钉盘单层 = depth=1 退化态,全量翻完当前目录不下钻),
// 过滤挂在 emitDriveDepthResult 管线内(pattern 名称筛之后、latest Top-N 之前)。
// 类型判定复用 route.isFolder 反判;时间比较直读 BFS 收集段注入的 sortTime 毫秒。
// ──────────────────────────────────────────────────────────
type driveListFilter struct {
nodeType string // ""=不过滤 / "file" / "folder"
startMs int64
endMs int64
hasStart bool
hasEnd bool
}
func (f driveListFilter) active() bool {
return f.nodeType != "" || f.hasStart || f.hasEnd
}
// parseDriveListFilter 读取并校验 --type/--start/--end。
// 三 flag 均未给值时返回零值(active()=false,调用方走存量分支)。
// 非法值 / start>end / 互斥组合在此拒绝(退出码 3,风格对齐 drive_latest.go 互斥条款)。
func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) {
var filter driveListFilter
nodeType, _ := cmd.Flags().GetString("type")
startRaw, _ := cmd.Flags().GetString("start")
endRaw, _ := cmd.Flags().GetString("end")
nodeType = strings.TrimSpace(nodeType)
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
if nodeType == "" && startRaw == "" && endRaw == "" {
return filter, nil
}
if nodeType != "" {
switch strings.ToLower(nodeType) {
case "file", "folder":
filter.nodeType = strings.ToLower(nodeType)
default:
return filter, &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--type 取值非法: %q(合法值: file|folder)", nodeType),
}
}
}
if startRaw != "" {
ms, err := parseDriveListTime(startRaw)
if err != nil {
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--start %s", err)}
}
filter.startMs = ms
filter.hasStart = true
}
if endRaw != "" {
ms, err := parseDriveListTime(endRaw)
if err != nil {
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--end %s", err)}
}
filter.endMs = ms
filter.hasEnd = true
}
if filter.hasStart && filter.hasEnd && filter.startMs > filter.endMs {
return filter, &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--start %q 晚于 --end %q,请修正时间范围", startRaw, endRaw),
}
}
// 互斥:filter 为 CLI 侧全量扫描模式,服务端分页/排序语义无法保持。
if cmd.Flags().Changed("versions") {
return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件")
}
// 用 Changed 判定而非值非空:显式 --cursor= 空值同样视为启用游标分页。
for _, f := range []string{"cursor", "next-token", "page-token"} {
if fl := cmd.Flags().Lookup(f); fl != nil && fl.Changed {
return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件")
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return filter, driveListFilterExclusiveError(f, "过滤模式输出由客户端筛选后重排,服务端排序语义无法保持;如需服务端排序请去掉过滤条件")
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return filter, driveListFilterExclusiveError("limit", "过滤模式为全量扫描,数据量由全局上限 2000 与目录范围控制;如需控制单页条数请去掉过滤条件")
}
return filter, nil
}
func driveListFilterExclusiveError(flag, guidance string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--type/--start/--end 不能与 --%s 同时使用:%s", flag, guidance),
}
}
// parseDriveListTime 单一时间解析入口:先试相对时间语法,失败回落 ISO8601
// (agoal.go parseISO8601ToMillis,RFC3339/无时区默认 Asia/Shanghai/仅日期)。
// 两 flag 共用同一入口;不接受毫秒时间戳(规约红线,与 search 历史遗留切割)。
func parseDriveListTime(value string) (int64, error) {
if ms, ok := parseDriveRelativeTimeAgo(value); ok {
return ms, nil
}
if ms, err := parseISO8601ToMillis(value); err == nil {
return ms, nil
}
return 0, fmt.Errorf("时间格式不支持: %q(支持: 相对时间如 24h/7d/2w、RFC3339 如 2026-08-01T00:00:00+08:00、无时区 ISO8601 如 2026-08-01 08:00:00(默认 Asia/Shanghai)、仅日期 2026-08-01;不支持毫秒时间戳与 m 单位)", value)
}
// parseDriveRelativeTimeAgo 相对时间 Nh/Nd/Nw(小时/天/周),按本机时钟换算为绝对毫秒。
// 不支持 m 单位(lark 双解析器 m=分钟/月语义打架的教训,直接规避);
// ok=false 表示非相对时间语法,由调用方回落 ISO8601 解析。
func parseDriveRelativeTimeAgo(value string) (int64, bool) {
s := strings.TrimSpace(value)
if len(s) < 2 {
return 0, false
}
var unit time.Duration
switch s[len(s)-1] {
case 'h':
unit = time.Hour
case 'd':
unit = 24 * time.Hour
case 'w':
unit = 7 * 24 * time.Hour
default:
return 0, false
}
n, err := strconv.Atoi(s[:len(s)-1])
if err != nil || n <= 0 {
return 0, false
}
return time.Now().Add(-time.Duration(n) * unit).UnixMilli(), true
}
// applyDriveListFilter 在 emit 管线内做类型/时间筛(pattern 之后、latest Top-N 之前)。
func applyDriveListFilter(items []map[string]any, route driveDepthRoute, filter driveListFilter) []map[string]any {
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
if filter.nodeType != "" {
folder := route.isFolder(item)
if filter.nodeType == "folder" && !folder {
continue
}
if filter.nodeType == "file" && folder {
continue
}
}
if filter.hasStart || filter.hasEnd {
// sortTime 由 BFS 收集段无条件注入(drive_depth.go 时间戳归一);
// 无时间信息的条目(sortTime<=0)在时间条件下不可判定,保守滤除。
ts, _ := item["sortTime"].(int64)
if ts <= 0 {
continue
}
if filter.hasStart && ts < filter.startMs {
continue
}
if filter.hasEnd && ts > filter.endMs {
continue
}
}
filtered = append(filtered, item)
}
return filtered
}
// callDriveListPageWithPattern 单层钉盘 --pattern 页内过滤(现状缺口附带修复:
// 纯透传分支此前未消费 pattern,flag 文案承诺的客户端过滤静默失效)。
// callMCPTool 透传即打印、无夹过滤的钩子,故取回解析后页内筛再输出;
// 输出保留原 body 形态(nextToken 等分页字段不动),仅条目数组被筛。
func callDriveListPageWithPattern(argsMap map[string]any, pattern string) error {
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": "list_files",
"arguments": argsMap,
"pattern": pattern,
"note": "pattern 为客户端过滤,仅作用于本页返回条目",
})
}
text, err := callMCPToolReturnText(context.Background(), "list_files", argsMap)
if err != nil {
return err
}
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
// 非 JSON 返回无法筛,原样输出(与透传分支的兜底形态一致)。
deps.Out.PrintRaw(text)
return nil
}
target := body
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
target = inner
}
if key := driveDepthListItemsKey(target); key != "" {
arr, _ := target[key].([]any)
filtered := make([]any, 0, len(arr))
for _, entry := range arr {
item, ok := entry.(map[string]any)
if !ok {
filtered = append(filtered, entry)
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if matchDriveNamePattern(name, pattern) {
filtered = append(filtered, entry)
}
}
target[key] = filtered
}
return deps.Out.PrintJSON(body)
}
+587
View File
@@ -0,0 +1,587 @@
package helpers
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// drive list --type/--start/--end 客户端过滤测试矩阵(设计稿 §9.3)。
// 命令级用例统一经 executeDriveListCapture 捕获 stdout JSON;
// 零值 filter 的存量分支行为由 drive_depth_test.go / drive_list_modes_test.go 既有用例兜底。
// 钉盘路由(serverID 空)经 resolveProductID 扫 os.Args 推断产品,命令级测试需伪装命令行。
func useDriveListArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = old })
}
func executeDriveListCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
deps.Out.errW = io.Discard
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return buf, err
}
// 1. 正向:--workspace --type file → list_nodes 全量拉取后仅 file,单层剥装饰字段。
func TestCrossPlatformCoverageDriveListFilterWorkspaceTypeFile(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"doc1","nodeType":"doc","updateTime":1754000000000},
{"nodeId":"n2","name":"sub","nodeType":"folder","updateTime":1754000000000}
],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
}
result := decodeDepthResult(t, buf)
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("filtered items = %#v", items)
}
item := items[0].(map[string]any)
if item["nodeId"] != "n1" {
t.Fatalf("item = %#v", item)
}
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := item[key]; ok {
t.Fatalf("decoration %q not stripped: %#v", key, item)
}
}
if result["truncated"] != false || result["maxDepth"] != float64(1) {
t.Fatalf("result = %#v", result)
}
}
// 2. 正向:--workspace --start 7d --end <RFC3339> → sortTime 区间断言。
func TestCrossPlatformCoverageDriveListFilterWorkspaceTimeRange(t *testing.T) {
now := time.Now()
within := now.Add(-24 * time.Hour).UnixMilli()
before := now.Add(-30 * 24 * time.Hour).UnixMilli()
after := now.Add(24 * time.Hour).UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"nodes":[
{"nodeId":"in","name":"within","nodeType":"doc","updateTime":%d},
{"nodeId":"old","name":"before","nodeType":"doc","updateTime":%d},
{"nodeId":"new","name":"after","nodeType":"doc","updateTime":%d}
],"hasMore":false}`, within, before, after)},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--start", "7d", "--end", now.Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["nodeId"] != "in" {
t.Fatalf("time-range items = %#v", items)
}
}
// 3. 组合:--workspace --depth 2 --type file → BFS 路径 filter 生效,装饰字段保留。
func TestCrossPlatformCoverageDriveListFilterWithDepthKeepsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"fA","name":"dirA","nodeType":"folder"},
{"nodeId":"n1","name":"doc1","nodeType":"doc"}
],"hasMore":false}`},
{text: `{"nodes":[{"nodeId":"n2","name":"doc2","nodeType":"doc"}],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--depth", "2", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2(depth=2 下钻 dirA)", caller.calls)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("filtered items = %#v(folder 应被滤除)", items)
}
for _, raw := range items {
item := raw.(map[string]any)
if _, ok := item["depth"]; !ok {
t.Fatalf("depth>1 装饰字段应保留: %#v", item)
}
}
}
// 4. 组合:--workspace --type file --latest 3 → filter 先筛后 Top-N;
// 触顶时 LATEST_SCAN_TRUNCATED 拒绝优先于 filter 的 partial 语义。
func TestCrossPlatformCoverageDriveListFilterWithLatestTopN(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"d1","name":"dir","nodeType":"folder","updateTime":400},
{"nodeId":"f1","name":"a","nodeType":"doc","updateTime":100},
{"nodeId":"f2","name":"b","nodeType":"doc","updateTime":300},
{"nodeId":"f3","name":"c","nodeType":"doc","updateTime":200},
{"nodeId":"f4","name":"d","nodeType":"doc","updateTime":50}
],"hasMore":false}`},
}}
buf, err := executeDriveListCapture(t, caller,
"list", "--workspace", "ws-1", "--type", "file", "--latest", "2")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("top-n items = %#v", items)
}
// folder(updateTime 最大)不得进入 Top-N 排序基;筛选后按 sortTime 倒序。
if items[0].(map[string]any)["nodeId"] != "f2" || items[1].(map[string]any)["nodeId"] != "f3" {
t.Fatalf("top-n order = %#v", items)
}
}
func TestCrossPlatformCoverageDriveListFilterLatestTruncatedRejected(t *testing.T) {
useDriveListArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
_, err := executeDriveListCapture(t, caller, "list", "--type", "file", "--latest", "2")
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err = %v, want LATEST_SCAN_TRUNCATED", err)
}
}
// 5. 钉盘路由:--folder + --type file 单层退化态全量翻页后仅 file;
// --start 7d --latest 3 走 BFS(depth=1);filter 与 cursor/order-by/order/limit/versions 互斥退出码 3。
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFile(t *testing.T) {
useDriveListArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"d1","name":"sub","type":"FOLDER"},
{"fileId":"f1","name":"a.txt","type":"FILE"}
],"nextToken":"p2"}`},
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want 2(翻完当前目录两页,sub 不下钻)", len(caller.calls))
}
if caller.calls[0]["parentId"] != "root-1" || caller.calls[0]["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("page args = %#v", caller.calls[0])
}
if caller.calls[1]["nextToken"] != "p2" {
t.Fatalf("page2 args = %#v", caller.calls[1])
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 2 {
t.Fatalf("filtered items = %#v", items)
}
for _, raw := range items {
item := raw.(map[string]any)
if item["type"] != "FILE" {
t.Fatalf("folder leaked: %#v", item)
}
if _, ok := item["depth"]; ok {
t.Fatalf("single-layer decorations not stripped: %#v", item)
}
}
}
func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) {
useDriveListArgs(t)
now := time.Now()
within := now.Add(-24 * time.Hour).UnixMilli()
stale := now.Add(-30 * 24 * time.Hour).UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":%d},
{"fileId":"f3","name":"c.txt","type":"FILE","modifyTime":%d}
]}`, within-1000, within, stale)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d", "--latest", "1")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
if _, ok := result["truncated"]; !ok {
t.Fatalf("filter+latest 应走 BFS 聚合形态: %#v", result)
}
items := result["items"].([]any)
// --start 7d 先筛掉 stale,Top-1 取 sortTime 最大的 f2。
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f2" {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) {
cases := [][]string{
{"list", "--type", "file", "--cursor", "c1"},
{"list", "--type", "file", "--cursor", ""},
{"list", "--type", "file", "--order-by", "name"},
{"list", "--start", "7d", "--order", "asc"},
{"list", "--type", "file", "--limit", "5"},
{"list", "--end", "2026-08-01", "--max", "5"},
{"list", "--versions", "--node", "n1", "--type", "file"},
}
for _, args := range cases {
caller := &scriptedToolCaller{}
_, err := executeDriveListCapture(t, caller, args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("args %v: err = %v, want CLIError exit 3", args, err)
}
if !strings.Contains(err.Error(), "不能与") || !strings.Contains(err.Error(), "同时使用") {
t.Fatalf("args %v: err = %v, want exclusivity message", args, err)
}
if caller.calls != 0 {
t.Fatalf("args %v: calls = %d, want 0", args, caller.calls)
}
}
}
// 6. 边界:--type 非法值列合法值;start>end 退出码 3;相对时间解析与 m/毫秒拒绝。
func TestCrossPlatformCoverageDriveListFilterInvalidValues(t *testing.T) {
caller := &scriptedToolCaller{}
_, err := executeDriveListCapture(t, caller, "list", "--type", "dir")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("--type dir err = %v, want exit 3", err)
}
if !strings.Contains(err.Error(), "file|folder") {
t.Fatalf("--type dir err = %v, want valid values listed", err)
}
_, err = executeDriveListCapture(t, caller, "list", "--start", "2026-08-10", "--end", "2026-08-01")
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("start>end err = %v, want exit 3", err)
}
if !strings.Contains(err.Error(), "晚于") {
t.Fatalf("start>end err = %v", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListFilterTimeParsing(t *testing.T) {
// 相对时间按本机时钟换算为绝对毫秒。
for _, tc := range []struct {
raw string
unit time.Duration
}{
{"24h", time.Hour},
{"7d", 24 * time.Hour},
{"2w", 7 * 24 * time.Hour},
} {
before := time.Now()
ms, err := parseDriveListTime(tc.raw)
after := time.Now()
if err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
expected := time.Duration(mustAtoi(t, tc.raw[:len(tc.raw)-1])) * tc.unit
lo := before.Add(-expected).UnixMilli()
hi := after.Add(-expected).UnixMilli()
if ms < lo || ms > hi {
t.Fatalf("%s: ms = %d, want in [%d, %d]", tc.raw, ms, lo, hi)
}
}
// RFC3339 / 无时区 ISO8601(默认 Asia/Shanghai)/ 仅日期。
shanghai := time.FixedZone("CST", 8*3600)
for _, tc := range []struct {
raw string
want int64
}{
{"2026-08-01T00:00:00+08:00", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
{"2026-08-01 08:00:00", time.Date(2026, 8, 1, 8, 0, 0, 0, shanghai).UnixMilli()},
{"2026-08-01", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
} {
ms, err := parseDriveListTime(tc.raw)
if err != nil || ms != tc.want {
t.Fatalf("%s: ms = %d err = %v, want %d", tc.raw, ms, err, tc.want)
}
}
// m 单位 / 毫秒时间戳 / 零与负值一律拒绝。
for _, raw := range []string{"7m", "30m", "1754000000000", "0d", "-3d", "abc"} {
if _, err := parseDriveListTime(raw); err == nil {
t.Fatalf("%q accepted, want rejection", raw)
}
}
}
func mustAtoi(t *testing.T, s string) int {
t.Helper()
n := 0
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
t.Fatalf("atoi %q: %v", s, err)
}
return n
}
// 7. dry-run:--workspace --type file --dry-run → printDriveDepthDryRun 路径,不发起调用。
func TestCrossPlatformCoverageDriveListFilterDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d", caller.calls)
}
result := decodeDepthResult(t, buf)
if result["dry_run"] != true || result["tool"] != "list_nodes" || result["maxDepth"] != float64(1) {
t.Fatalf("dry-run payload = %#v", result)
}
}
// 8. 触顶:2000 条上限 → partial + truncated=true + 退出码 0(filter 触顶结果不全但不会错)。
func TestCrossPlatformCoverageDriveListFilterTruncatedPartial(t *testing.T) {
useDriveListArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
buf, err := executeDriveListCapture(t, caller, "list", "--type", "file")
if err != nil {
t.Fatalf("filter 触顶应放行(退出码 0): %v", err)
}
result := decodeDepthResult(t, buf)
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if got := len(result["items"].([]any)); got != driveDepthMaxItems {
t.Fatalf("items len = %d, want %d(全 FILE 类型筛全保留)", got, driveDepthMaxItems)
}
}
// 9. 回归:不带 filter 的存量分支行为不变;--pattern 单层钉盘页内过滤为本次有意修复。
func TestCrossPlatformCoverageDriveListFilterRegressionUnaffectedPaths(t *testing.T) {
useDriveListArgs(t)
// 单层纯透传(无 filter/pattern):body 原样输出,args 语义不变。
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt"}],"nextToken":"nt"}`},
}}
buf, err := executeDriveListCapture(t, caller, "list")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
if result["nextToken"] != "nt" || len(result["items"].([]any)) != 1 {
t.Fatalf("透传 body 被改变: %#v", result)
}
if _, ok := result["truncated"]; ok {
t.Fatalf("透传形态不应含 BFS 聚合字段: %#v", result)
}
if caller.args["maxResults"] != float64(20) {
t.Fatalf("args = %#v", caller.args)
}
// workspace 单层透传:无 filter/depth/latest 时仍走单页 list_nodes。
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
}}
buf2, err := executeDriveListCapture(t, caller2, "list", "--workspace", "ws-1")
if err != nil {
t.Fatal(err)
}
result2 := decodeDepthResult(t, buf2)
if _, ok := result2["nodes"]; !ok {
t.Fatalf("workspace 单层透传形态被改变: %#v", result2)
}
if caller2.args["pageSize"] != 20 || caller2.args["workspaceId"] != "ws-1" {
t.Fatalf("args = %#v", caller2.args)
}
// 钉盘单层 --latest(无 filter):仍走 runDriveListLatest(非 BFS 聚合形态)。
now := time.Now().UnixMilli()
caller3 := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}],"nextToken":""}`, now)},
}}
buf3, err := executeDriveListCapture(t, caller3, "list", "--latest", "1")
if err != nil {
t.Fatal(err)
}
result3 := decodeDepthResult(t, buf3)
if _, ok := result3["truncated"]; ok {
t.Fatalf("单层 latest 不应切到 BFS 聚合形态: %#v", result3)
}
if len(result3["items"].([]any)) != 1 {
t.Fatalf("items = %#v", result3["items"])
}
if caller3.calls[0]["orderBy"] != "modifyTime" || caller3.calls[0]["order"] != "desc" {
t.Fatalf("latest 扫描参数被改变: %#v", caller3.calls[0])
}
}
func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) {
useDriveListArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","name":"a.txt"},
{"fileId":"f2","name":"b.md"}
],"nextToken":"nt"}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, buf)
items := result["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("pattern 页内过滤未生效: %#v", items)
}
if result["nextToken"] != "nt" {
t.Fatalf("分页字段应保留: %#v", result)
}
}
// 10. 钉盘路由:--type folder 反向筛(FILE 被滤除,仅 FOLDER 保留)。
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFolder(t *testing.T) {
useDriveListArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"d1","name":"sub","type":"FOLDER"},
{"fileId":"f1","name":"a.txt","type":"FILE"}
]}`},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "folder")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d1" {
t.Fatalf("folder filter items = %#v", items)
}
}
// 11. 钉盘路由:--start 下无时间信息的条目(sortTime<=0)不可判定,保守滤除。
func TestCrossPlatformCoverageDriveListFilterPanMissingTimeDropped(t *testing.T) {
useDriveListArgs(t)
now := time.Now().UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
{"fileId":"f2","name":"no-time.txt","type":"FILE"}
]}`, now)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("missing-time 条目应被滤除: %#v", items)
}
}
// 12. 单层钉盘 --pattern 页内过滤边界:dry-run 预览 / MCP 错误透传 / 非 JSON 原样输出 /
// 非 map 条目保留 + name 缺失时 fileName 兜底匹配。
func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) {
useDriveListArgs(t)
// dry-run:打印预览,不发起调用。
dryCaller := &scriptedToolCaller{dry: true}
buf, err := executeDriveListCapture(t, dryCaller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
if dryCaller.calls != 0 {
t.Fatalf("dry-run calls = %d", dryCaller.calls)
}
preview := decodeDepthResult(t, buf)
if preview["dry_run"] != true || preview["tool"] != "list_files" || preview["pattern"] != "*.txt" {
t.Fatalf("dry-run payload = %#v", preview)
}
// MCP 错误:原样返回。
errCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
if _, err := executeDriveListCapture(t, errCaller, "list", "--pattern", "*.txt"); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("err = %v, want boom", err)
}
// 非 JSON 返回:无法筛,原样输出(与透传分支兜底形态一致)。
rawCaller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "plain-text-result"}}}
rawBuf, err := executeDriveListCapture(t, rawCaller, "list", "--pattern", "*.txt")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rawBuf.String(), "plain-text-result") {
t.Fatalf("raw output = %q", rawBuf.String())
}
// 非 map 条目原样保留;name 缺失时 fileName 兜底参与匹配。
mixedCaller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
"raw-entry",
{"fileId":"f1","fileName":"日报模板.doc"},
{"fileId":"f2","name":"其他.txt"}
],"nextToken":"nt"}`},
}}
mixedBuf, err := executeDriveListCapture(t, mixedCaller, "list", "--pattern", "*模板*")
if err != nil {
t.Fatal(err)
}
mixed := decodeDepthResult(t, mixedBuf)["items"].([]any)
if len(mixed) != 2 || mixed[0] != "raw-entry" || mixed[1].(map[string]any)["fileId"] != "f1" {
t.Fatalf("items = %#v", mixed)
}
}
// 13. --type folder --latest 回归(P1 CR):latest 对过滤后的目录按时间取 Top-N,
// 不再「先留目录再剔目录」返回空列表。
func TestCrossPlatformCoverageDriveListFilterFolderLatest(t *testing.T) {
useDriveListArgs(t)
now := time.Now().UnixMilli()
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[
{"fileId":"d1","name":"old-dir","type":"FOLDER","modifyTime":%d},
{"fileId":"d2","name":"new-dir","type":"FOLDER","modifyTime":%d},
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}
]}`, now-5000, now, now)},
}}
buf, err := executeDriveListCapture(t, caller, "list", "--type", "folder", "--latest", "1")
if err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, buf)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d2" {
t.Fatalf("folder latest items = %#v", items)
}
}
+576
View File
@@ -0,0 +1,576 @@
// 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 helpers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
const (
fileCommentServer = "doc-comment"
listFileCommentsTool = "list_file_comments"
createFileCommentTool = "create_file_comment"
fileCommentMaxPageSize = 200
fileCommentMaxAutoPages = 10
fileCommentMaxContentLength = 2099
)
type fileCommentPage struct {
nodeID string
total any
hasMore bool
nextCursor string
comments []map[string]any
}
func fileCommentNodeFlag() LeafFlag {
return LeafFlag{Name: "node", Usage: "文件 ID (dentryUuid)、数字 dentry ID 或钉盘文件 URL", Required: true, Aliases: []string{"url", "id", "node-id", "file-id"}, Bind: "fileId", Trim: true}
}
func fileCommentSpaceIDFlag() LeafFlag {
return LeafFlag{Name: "space-id", Usage: "钉盘空间 ID;仅数字 dentry ID 必填", Bind: "spaceId", OmitEmpty: true, Trim: true, RequiredWhen: "--node is a numeric dentry ID"}
}
// newDriveFileCommentCmd follows the existing resource-first comment surface:
// 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{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
Short: "查询普通文件评论列表",
Long: `查询钉盘普通预览文件的评论。支持 dentryUuid、钉盘文件 URL,以及配合
--space-id 使用的数字 dentry ID。支持的文件类型由服务端判定。
默认返回一页;--all 固定按每页 200 条自动翻页,最多 10 页。--scope 在 CLI
侧按服务端返回的统一 anchor 过滤;total 始终表示文件的全部有效评论数,count
表示本次输出且符合 scope 的评论数。`,
Example: ` dws drive comment list --node <dentryUuid> --format json
dws drive comment list --node <dentryUuid> --all --format json`,
Tool: listFileCommentsTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "limit", Usage: "每页评论数,范围 1-200", Kind: LeafInt, Default: "200", Aliases: []string{"page-size"}, Bind: "maxResults"},
{Name: "cursor", Usage: "分页游标,取自上页 nextCursor", Bind: "nextToken", OmitEmpty: true, Trim: true},
{Name: "all", Usage: "自动拉取全部评论,最多 10 页 / 2000 条", Kind: LeafBool, Bind: "all"},
{Name: "scope", Usage: "评论范围: all(全部) / whole(全文) / partial(历史局部)", Default: "all", Bind: "scope", Trim: true, Enum: []string{"all", "whole", "partial"}},
},
Constraints: []LeafConstraint{
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "cursor"}, Description: "--all 与 --cursor 互斥"},
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "limit"}, Description: "--all 与显式 --limit/--page-size 互斥"},
{Kind: "custom", Flags: []string{"limit"}, Description: "--limit/--page-size 必须在 1-200 之间"},
{Kind: "custom", Flags: []string{"cursor"}, Description: "--cursor 必须是服务端返回的非负数字游标"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_file_comments",
CanonicalPath: "drive.list_file_comments",
CLIPath: "drive comment list",
PrimaryCLIPath: "drive comment list",
},
Description: "查询钉盘普通预览文件评论,支持安全分页聚合和全文/局部范围过滤",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/list_file_comments with bounded auto-pagination, cursor anomaly guards, local anchor-scope filtering, and a stable output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询 PDF、DOCX、XLSX 等钉盘普通预览文件上的评论",
UseWhen: []string{
"用户要查看普通文件上的全文评论或历史高亮/矩形评论时",
"需要从评论列表取得 commentId、作者、时间和统一 anchor,或用 --all 拉取完整列表时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment list",
"在线表格单元格评论使用 dws sheet comment list",
},
Examples: []string{
"dws drive comment list --node <dentryUuid> --format json",
"dws drive comment list --node <dentryUuid> --all --format json",
},
},
},
Validate: validateFileCommentList,
Call: runFileCommentList,
})
createCmd := NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建普通文件全文评论",
Long: `在钉盘普通预览文件上创建一条全文纯文本评论。当前不支持 @人、通知选项或
局部锚点;content 按服务端规则使用 UTF-16 长度计数,最多 2099。`,
Example: ` dws drive comment create --node <dentryUuid> --content "请补充最终结论" --format json`,
Tool: createFileCommentTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "content", Usage: "全文评论内容,纯文本且 UTF-16 长度不超过 2099", Required: true, Bind: "content"},
},
Constraints: []LeafConstraint{
{Kind: "custom", Flags: []string{"content"}, Description: "--content 去除首尾空白后必须非空,且 UTF-16 长度不超过 2099"},
},
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "create_file_comment",
CanonicalPath: "drive.create_file_comment",
CLIPath: "drive comment create",
PrimaryCLIPath: "drive comment create",
},
Description: "在钉盘普通预览文件上创建一条全文纯文本评论",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/create_file_comment with exact local content validation, runtime confirmation, and a stable flattened output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "在 PDF、DOCX、XLSX 等钉盘普通预览文件上创建全文纯文本评论",
UseWhen: []string{
"用户明确要求在普通文件上留下不绑定具体位置的评论时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment create",
"在线表格单元格评论使用 dws sheet comment create;当前普通文件评论不支持 @人或局部锚点",
},
Examples: []string{
"dws drive comment create --node <dentryUuid> --content \"请补充最终结论\" --format json",
},
},
},
Validate: validateFileCommentCreate,
Call: runFileCommentCreate,
})
commentCmd.AddCommand(listCmd, createCmd)
return commentCmd
}
func validateFileCommentList(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
limit, _ := cmd.Flags().GetInt("limit")
if cmd.Flags().Changed("page-size") {
limit, _ = cmd.Flags().GetInt("page-size")
}
if limit < 1 || limit > fileCommentMaxPageSize {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--limit/--page-size 必须在 1-%d 之间", fileCommentMaxPageSize),
}
}
if cursor, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(cursor) != "" {
cursor = strings.TrimSpace(cursor)
if !allASCIIDigits(cursor) {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 必须是服务端返回的非负数字游标"}
}
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 超出 64 位整数范围"}
}
}
return nil
}
func validateFileCommentCreate(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
content, _ := cmd.Flags().GetString("content")
if strings.TrimSpace(content) == "" {
return &CLIError{Code: CodeInvalidParam, Message: "--content 去除首尾空白后不能为空"}
}
length := fileCommentUTF16Length(content)
if length > fileCommentMaxContentLength {
return &CLIError{
Code: CodeInputTooLarge,
Message: fmt.Sprintf("--content 最多 %d 个 UTF-16 代码单元,当前为 %d", fileCommentMaxContentLength, length),
}
}
return nil
}
func validateFileCommentNodeSpace(cmd *cobra.Command) error {
node := corecmd.EffectiveValue(cmd, fileCommentNodeFlag())
if !allASCIIDigits(node) {
return nil
}
if spaceID := corecmd.EffectiveValue(cmd, fileCommentSpaceIDFlag()); spaceID == "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--node 为数字 dentry ID 时必须同时提供 --space-id",
}
}
return nil
}
func fileCommentUTF16Length(value string) int {
length := 0
for _, r := range value {
length++
if r > 0xffff {
length++
}
}
return length
}
func allASCIIDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func runFileCommentList(cmd *cobra.Command, tool string, args map[string]any) error {
fetchAll, _ := args["all"].(bool)
scope, _ := args["scope"].(string)
if scope == "" {
scope = "all"
}
request := fileCommentMCPArgs(args)
if fetchAll {
request["maxResults"] = fileCommentMaxPageSize
delete(request, "nextToken")
}
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
if !fetchAll {
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if err := validateFileCommentNextCursor(page, stringArg(request, "nextToken")); err != nil {
return err
}
page.comments = filterFileCommentsByScope(page.comments, scope)
return output.WriteCommandPayload(cmd, fileCommentListPayload(page, scope), output.FormatJSON)
}
var aggregate fileCommentPage
aggregate.comments = make([]map[string]any, 0)
seenCursors := map[string]bool{}
currentCursor := ""
for pageNumber := 0; pageNumber < fileCommentMaxAutoPages; pageNumber++ {
if currentCursor == "" {
delete(request, "nextToken")
} else {
request["nextToken"] = currentCursor
}
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if pageNumber == 0 {
aggregate.nodeID = page.nodeID
aggregate.total = page.total
}
aggregate.comments = append(aggregate.comments, filterFileCommentsByScope(page.comments, scope)...)
if !page.hasMore {
aggregate.hasMore = false
aggregate.nextCursor = ""
return output.WriteCommandPayload(cmd, fileCommentListPayload(aggregate, scope), output.FormatJSON)
}
if err := validateFileCommentNextCursor(page, currentCursor); err != nil {
return err
}
if seenCursors[page.nextCursor] {
return fileCommentPaginationError("分页游标发生循环,结果可能不完整")
}
seenCursors[page.nextCursor] = true
currentCursor = page.nextCursor
}
return fileCommentPaginationError(fmt.Sprintf("自动翻页达到 %d 页上限但服务端仍返回 hasMore=true,结果可能不完整", fileCommentMaxAutoPages))
}
func runFileCommentCreate(cmd *cobra.Command, tool string, args map[string]any) error {
request := fileCommentMCPArgs(args)
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
comment, ok := payload["comment"].(map[string]any)
if !ok {
return invalidFileCommentResponse(tool, "缺少 comment 对象", nil)
}
if err := validateFileCommentItem(tool, "comment", comment); err != nil {
return err
}
out := map[string]any{"nodeId": nodeID}
for key, value := range projectFileComment(comment) {
out[key] = value
}
return output.WriteCommandPayload(cmd, out, output.FormatJSON)
}
func fileCommentMCPArgs(args map[string]any) map[string]any {
out := make(map[string]any, len(args))
for key, value := range args {
switch key {
case "all", "scope":
continue
default:
out[key] = value
}
}
return out
}
func fetchFileCommentPage(tool string, request map[string]any) (fileCommentPage, error) {
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return fileCommentPage{}, err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return fileCommentPage{}, err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
total, ok := payload["total"]
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 total", nil)
}
hasMore, ok := payload["hasMore"].(bool)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少布尔字段 hasMore", nil)
}
nextCursor := ""
if value, exists := payload["nextToken"]; exists && value != nil {
var stringValue bool
nextCursor, stringValue = value.(string)
if !stringValue {
return fileCommentPage{}, invalidFileCommentResponse(tool, "nextToken 不是字符串", nil)
}
}
rawItems, exists := payload["items"]
if !exists {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 items", nil)
}
items, ok := rawItems.([]any)
if rawItems == nil {
items = []any{}
ok = true
}
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "items 不是数组", nil)
}
comments := make([]map[string]any, 0, len(items))
for index, item := range items {
comment, ok := item.(map[string]any)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, fmt.Sprintf("items[%d] 不是对象", index), nil)
}
if err := validateFileCommentItem(tool, fmt.Sprintf("items[%d]", index), comment); err != nil {
return fileCommentPage{}, err
}
comments = append(comments, projectFileComment(comment))
}
return fileCommentPage{
nodeID: nodeID, total: total, hasMore: hasMore,
nextCursor: strings.TrimSpace(nextCursor), comments: comments,
}, nil
}
func decodeFileCommentPayload(tool, raw string) (map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidFileCommentResponse(tool, "返回为空", nil)
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, invalidFileCommentResponse(tool, "返回不是有效 JSON", err)
}
for depth := 0; depth < 2; depth++ {
if _, ok := payload["fileId"]; ok {
break
}
var nested map[string]any
for _, key := range []string{"result", "data"} {
if value, ok := payload[key].(map[string]any); ok {
nested = value
break
}
}
if nested == nil {
break
}
payload = nested
}
return payload, nil
}
func projectFileComment(comment map[string]any) map[string]any {
out := map[string]any{}
for _, key := range []string{"commentId", "parentCommentId", "content", "createdAt", "updatedAt", "options", "anchor"} {
if value, ok := comment[key]; ok {
out[key] = value
}
}
if value, ok := comment["commentCustomType"]; ok {
out["customType"] = value
}
creator := map[string]any{}
for source, target := range map[string]string{
"creatorId": "userId", "creatorName": "name", "creatorAvatar": "avatar",
} {
if value, ok := comment[source]; ok {
creator[target] = value
}
}
if len(creator) > 0 {
out["creator"] = creator
}
return out
}
func validateFileCommentItem(tool, path string, comment map[string]any) error {
if _, ok := nonEmptyStringField(comment, "commentId"); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 commentId", nil)
}
if _, ok := comment["anchor"].(map[string]any); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 anchor 对象", nil)
}
return nil
}
func filterFileCommentsByScope(comments []map[string]any, scope string) []map[string]any {
if scope == "" || scope == "all" {
return comments
}
out := make([]map[string]any, 0, len(comments))
for _, comment := range comments {
anchor, _ := comment["anchor"].(map[string]any)
commentScope, _ := anchor["scope"].(string)
if commentScope == scope {
out = append(out, comment)
}
}
return out
}
func fileCommentListPayload(page fileCommentPage, scope string) map[string]any {
comments := page.comments
if comments == nil {
comments = make([]map[string]any, 0)
}
nextCursor := any(nil)
if page.nextCursor != "" {
nextCursor = page.nextCursor
}
return map[string]any{
"nodeId": page.nodeID,
"total": page.total,
"count": len(page.comments),
"hasMore": page.hasMore,
"nextCursor": nextCursor,
"complete": !page.hasMore,
"scope": scope,
"comments": comments,
}
}
func validateFileCommentNextCursor(page fileCommentPage, currentCursor string) error {
if !page.hasMore {
return nil
}
if page.nextCursor == "" {
return fileCommentPaginationError("服务端返回 hasMore=true 但 nextCursor 为空,结果可能不完整")
}
if !allASCIIDigits(page.nextCursor) {
return fileCommentPaginationError("服务端返回的 nextCursor 不是非负数字游标,结果可能不完整")
}
if _, err := strconv.ParseInt(page.nextCursor, 10, 64); err != nil {
return fileCommentPaginationError("服务端返回的 nextCursor 超出 64 位整数范围,结果可能不完整")
}
if page.nextCursor == currentCursor {
return fileCommentPaginationError("服务端分页游标未前进,结果可能不完整")
}
return nil
}
func fileCommentPaginationError(message string) error {
return &CLIError{
Code: CodeContentTruncated,
Message: message,
Suggestion: "请稍后重试,或去掉 --all 后使用服务端返回的 --cursor 分页读取",
Operation: fileCommentServer + "/" + listFileCommentsTool,
}
}
func invalidFileCommentResponse(tool, reason string, cause error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("%s 返回结构异常:%s", tool, reason),
Suggestion: "请确认 doc-comment MCP 与当前 DWS 契约一致",
Operation: fileCommentServer + "/" + tool,
Cause: cause,
}
}
func nonEmptyStringField(payload map[string]any, key string) (string, bool) {
value, ok := payload[key].(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
func stringArg(args map[string]any, key string) string {
value, _ := args[key].(string)
return strings.TrimSpace(value)
}
+647
View File
@@ -0,0 +1,647 @@
// 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 helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type fileCommentTestCall struct {
server string
tool string
args map[string]any
}
type fileCommentTestCaller struct {
calls []fileCommentTestCall
responses []string
err error
dryRun bool
}
func (c *fileCommentTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, fileCommentTestCall{server: server, tool: tool, args: copied})
if c.err != nil {
return nil, c.err
}
response := `{"result":{"fileId":"file-1","comment":{"commentId":"1","content":"ok","anchor":{"version":"v1","surface":"file","scope":"whole"}}}}`
if len(c.responses) > 0 {
response = c.responses[0]
c.responses = c.responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (c *fileCommentTestCaller) Format() string { return "json" }
func (c *fileCommentTestCaller) DryRun() bool { return c.dryRun }
func (c *fileCommentTestCaller) Fields() string { return "" }
func (c *fileCommentTestCaller) JQ() string { return "" }
func executeFileCommentCommand(t *testing.T, caller *fileCommentTestCaller, stdin string, args ...string) ([]byte, error) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(stdin))
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
drive := &cobra.Command{Use: "drive"}
drive.AddCommand(newDriveFileCommentCmd())
root.AddCommand(drive)
var setOutput func(*cobra.Command)
setOutput = func(command *cobra.Command) {
command.SetOut(&stdout)
command.SetErr(io.Discard)
for _, child := range command.Commands() {
setOutput(child)
}
}
setOutput(root)
root.SetArgs(args)
err := root.Execute()
return stdout.Bytes(), err
}
func decodeFileCommentTestOutput(t *testing.T, raw []byte) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode output %q: %v", raw, err)
}
return payload
}
func TestDriveFileCommentListMapsFiltersAndProjects(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"result": {
"fileId": "resolved-file",
"total": 2,
"count": 2,
"hasMore": false,
"nextToken": null,
"items": [
{
"commentId": "101",
"parentCommentId": null,
"content": "whole",
"creatorId": "user-1",
"creatorName": "Alice",
"creatorAvatar": "https://avatar/1",
"createdAt": 1785920000000,
"updatedAt": 1785920000100,
"commentCustomType": "common",
"anchor": {"version":"v1","surface":"file","scope":"whole"}
},
{
"commentId": "102",
"content": "partial",
"commentCustomType": "highlight",
"options": {"page":"1"},
"anchor": {"version":"v1","surface":"file","scope":"partial","selector":{"kind":"legacy-highlight"}}
}
]
}
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list",
"--node", "https://alidocs.dingtalk.com/i/drive/file",
"--space-id", "123", "--limit", "20", "--scope", "whole",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.server != fileCommentServer || call.tool != listFileCommentsTool {
t.Fatalf("target = %s/%s", call.server, call.tool)
}
wantArgs := map[string]any{
"fileId": "https://alidocs.dingtalk.com/i/drive/file",
"spaceId": "123",
"maxResults": 20,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["total"] != float64(2) ||
payload["count"] != float64(1) || payload["complete"] != true || payload["scope"] != "whole" {
t.Fatalf("list output = %#v", payload)
}
if payload["nextCursor"] != nil || payload["hasMore"] != false {
t.Fatalf("pagination output = %#v", payload)
}
comments := payload["comments"].([]any)
comment := comments[0].(map[string]any)
if comment["commentId"] != "101" || comment["customType"] != "common" {
t.Fatalf("comment projection = %#v", comment)
}
creator := comment["creator"].(map[string]any)
if creator["userId"] != "user-1" || creator["name"] != "Alice" || creator["avatar"] != "https://avatar/1" {
t.Fatalf("creator projection = %#v", creator)
}
if _, leaked := comment["creatorId"]; leaked {
t.Fatalf("raw creator fields leaked: %#v", comment)
}
}
func TestDriveFileCommentListKeepsEmptyCommentsAsArray(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1",
)
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
comments, ok := payload["comments"].([]any)
if !ok || len(comments) != 0 {
t.Fatalf("comments = %#v, want an empty JSON array", payload["comments"])
}
if payload["total"] != float64(0) || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("empty list output = %#v", payload)
}
}
func TestDriveFileCommentListAllAggregatesPages(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":3,"count":2,"hasMore":true,"nextToken":"2","items":[
{"commentId":"1","anchor":{"scope":"whole"}},
{"commentId":"2","anchor":{"scope":"partial"}}
]}`,
`{"fileId":"file-1","total":3,"count":1,"hasMore":false,"nextToken":null,"items":[
{"commentId":"3","anchor":{"scope":"partial"}}
]}`,
}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1", "--all", "--scope", "partial",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["maxResults"] != fileCommentMaxPageSize {
t.Fatalf("first args = %#v", caller.calls[0].args)
}
if _, ok := caller.calls[0].args["nextToken"]; ok {
t.Fatalf("first page unexpectedly has cursor: %#v", caller.calls[0].args)
}
if caller.calls[1].args["nextToken"] != "2" {
t.Fatalf("second args = %#v", caller.calls[1].args)
}
for _, call := range caller.calls {
if _, ok := call.args["all"]; ok {
t.Fatalf("local --all leaked to MCP: %#v", call.args)
}
if _, ok := call.args["scope"]; ok {
t.Fatalf("local --scope leaked to MCP: %#v", call.args)
}
}
payload := decodeFileCommentTestOutput(t, out)
if payload["total"] != float64(3) || payload["count"] != float64(2) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("aggregate output = %#v", payload)
}
comments := payload["comments"].([]any)
if comments[0].(map[string]any)["commentId"] != "2" || comments[1].(map[string]any)["commentId"] != "3" {
t.Fatalf("scope-filtered comments = %#v", comments)
}
}
func TestDriveFileCommentListRejectsPaginationAnomalies(t *testing.T) {
tests := []struct {
name string
args []string
response string
message string
}{
{
name: "missing cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
response: `{"fileId":"file-1","total":1,"count":1,"hasMore":true,"nextToken":null,"items":[]}`,
message: "nextCursor 为空",
},
{
name: "stalled cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--cursor", "2"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"2","items":[]}`,
message: "游标未前进",
},
{
name: "invalid server cursor",
args: []string{"drive", "comment", "list", "--node", "file-1"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"next","items":[]}`,
message: "不是非负数字游标",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentListValidatesLocalParameters(t *testing.T) {
tests := []struct {
name string
args []string
}{
{"all with cursor", []string{"--all", "--cursor", "1"}},
{"all with limit", []string{"--all", "--limit", "20"}},
{"zero limit", []string{"--limit", "0"}},
{"large limit", []string{"--page-size", "201"}},
{"nonnumeric cursor", []string{"--cursor", "next"}},
{"overflow cursor", []string{"--cursor", "999999999999999999999999999999"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
args := []string{"drive", "comment", "list", "--node", "file-1"}
args = append(args, tt.args...)
if _, err := executeFileCommentCommand(t, caller, "", args...); err == nil {
t.Fatal("invalid arguments unexpectedly succeeded")
}
if len(caller.calls) != 0 {
t.Fatalf("invalid arguments reached MCP: %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentNumericNodeRequiresSpaceIDForListAndCreateAliases(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "list id alias",
args: []string{"drive", "comment", "list", "--id", "231773999335"},
},
{
name: "create file-id alias",
args: []string{"drive", "comment", "create", "--file-id", "231773999335", "--content", "test", "--yes"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeInvalidParam || !strings.Contains(cliErr.Message, "--space-id") {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("numeric node without space-id reached MCP: %#v", caller.calls)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"resolved-file","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
if _, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--url", "231773999335", "--space-id", "2402756201",
); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].args["fileId"] != "231773999335" || caller.calls[0].args["spaceId"] != "2402756201" {
t.Fatalf("numeric node with space-id args = %#v", caller.calls)
}
if allASCIIDigits("") {
t.Fatal("empty string unexpectedly accepted as numeric")
}
}
func TestDriveFileCommentCreateValidatesMapsAndProjects(t *testing.T) {
validContent := strings.Repeat("a", fileCommentMaxContentLength)
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"resolved-file","comment":{
"commentId":"912345","parentCommentId":null,"content":"created","creatorId":"user-1",
"createdAt":1785920000000,"commentCustomType":"common",
"anchor":{"version":"v1","surface":"file","scope":"whole","selector":null}
}}}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--space-id", "123",
"--content", validContent, "--yes",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != fileCommentServer || caller.calls[0].tool != createFileCommentTool {
t.Fatalf("calls = %#v", caller.calls)
}
wantArgs := map[string]any{"fileId": "file-1", "spaceId": "123", "content": validContent}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.calls[0].args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["commentId"] != "912345" ||
payload["content"] != "created" || payload["customType"] != "common" {
t.Fatalf("create output = %#v", payload)
}
if _, nested := payload["comment"]; nested {
t.Fatalf("create output was not flattened: %#v", payload)
}
for _, tc := range []struct {
name string
content string
}{
{"blank", " \t"},
{"ascii over limit", strings.Repeat("a", fileCommentMaxContentLength+1)},
{"utf16 over limit", strings.Repeat("😀", 1050)},
} {
t.Run(tc.name, func(t *testing.T) {
invalidCaller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, invalidCaller, "",
"drive", "comment", "create", "--node", "file-1", "--content", tc.content, "--yes",
)
if err == nil {
t.Fatal("invalid content unexpectedly succeeded")
}
if len(invalidCaller.calls) != 0 {
t.Fatalf("invalid content reached MCP: %#v", invalidCaller.calls)
}
})
}
}
func TestDriveFileCommentCreatePublishesAndEnforcesConfirmation(t *testing.T) {
group := newDriveFileCommentCmd()
create, remaining, err := group.Find([]string{"create"})
if err != nil || len(remaining) != 0 {
t.Fatalf("find create: remaining=%v err=%v", remaining, err)
}
final, ok := contractfinal.RuntimeContractFinal(create)
if !ok || final.Safety == nil {
t.Fatal("create command is missing ContractFinal Safety")
}
if safety := *final.Safety; safety.Effect != "write" || safety.Risk != "medium" ||
safety.Confirmation != "user_required" || safety.Idempotency != "unknown" {
t.Fatalf("create safety = %#v", safety)
}
caller := &fileCommentTestCaller{}
_, err = executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "需要确认",
)
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("closed-stdin error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed create reached MCP: %#v", caller.calls)
}
}
func TestDriveFileCommentRejectsMalformedResponseInsteadOfReturningEmpty(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"file-1","total":0,"hasMore":false}}`}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, "缺少 items") {
t.Fatalf("error = %#v", err)
}
}
func TestDriveFileCommentDryRunAndCallErrors(t *testing.T) {
for _, tt := range []struct {
name string
args []string
tool string
}{
{
name: "list dry run",
args: []string{"drive", "comment", "list", "--node", "file-1"},
tool: listFileCommentsTool,
},
{
name: "create dry run",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
tool: createFileCommentTool,
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{dryRun: true}
out, err := executeFileCommentCommand(t, caller, "", tt.args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 || !strings.Contains(string(out), `"tool": "`+tt.tool+`"`) {
t.Fatalf("calls = %#v, output = %s", caller.calls, out)
}
})
}
sentinel := errors.New("mcp unavailable")
for _, tt := range []struct {
name string
args []string
}{
{
name: "list call error",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
},
{
name: "create call error",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{err: sentinel}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
if !errors.Is(err, sentinel) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentListResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "empty", response: "", message: "返回为空"},
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{}`, message: "缺少 fileId"},
{name: "missing total", response: `{"fileId":"file-1","hasMore":false,"items":[]}`, message: "缺少 total"},
{name: "missing has more", response: `{"fileId":"file-1","total":0,"items":[]}`, message: "缺少布尔字段 hasMore"},
{name: "next token type", response: `{"fileId":"file-1","total":1,"hasMore":true,"nextToken":2,"items":[]}`, message: "nextToken 不是字符串"},
{name: "items type", response: `{"fileId":"file-1","total":0,"hasMore":false,"items":{}}`, message: "items 不是数组"},
{name: "item type", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[1]}`, message: "items[0] 不是对象"},
{name: "missing comment id", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"anchor":{}}]}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"commentId":"1"}]}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"data":{"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":null}
}`}}
out, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
if comments := decodeFileCommentTestOutput(t, out)["comments"].([]any); len(comments) != 0 {
t.Fatalf("comments = %#v", comments)
}
}
func TestDriveFileCommentCreateResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{"comment":{"commentId":"1","anchor":{}}}`, message: "缺少 fileId"},
{name: "missing comment", response: `{"fileId":"file-1"}`, message: "缺少 comment 对象"},
{name: "missing comment id", response: `{"fileId":"file-1","comment":{"anchor":{}}}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","comment":{"commentId":"1"}}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes",
)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentPaginationCycleLimitAndPartialPage(t *testing.T) {
cycleCaller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"3","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
}}
_, err := executeFileCommentCommand(t, cycleCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "发生循环") {
t.Fatalf("cycle error = %#v", err)
}
limitResponses := make([]string, 0, fileCommentMaxAutoPages)
for index := 0; index < fileCommentMaxAutoPages; index++ {
limitResponses = append(limitResponses, fmt.Sprintf(
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"%d","items":[]}`,
index+1,
))
}
limitCaller := &fileCommentTestCaller{responses: limitResponses}
_, err = executeFileCommentCommand(t, limitCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "10 页上限") {
t.Fatalf("page limit error = %#v", err)
}
partialCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":1,"hasMore":true,"nextToken":"2",
"items":[{"commentId":"1","anchor":{"scope":"whole"}}]
}`}}
out, err := executeFileCommentCommand(t, partialCaller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nextCursor"] != "2" || payload["complete"] != false || payload["hasMore"] != true {
t.Fatalf("partial page = %#v", payload)
}
overflowCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":true,
"nextToken":"999999999999999999999999999999","items":[]
}`}}
_, err = executeFileCommentCommand(t, overflowCaller, "", "drive", "comment", "list", "--node", "file-1")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "超出 64 位整数范围") {
t.Fatalf("overflow cursor error = %#v", err)
}
}
func TestDriveFileCommentInternalDefaults(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "list"}
cmd.SetOut(&stdout)
if err := runFileCommentList(cmd, listFileCommentsTool, map[string]any{"fileId": "file-1", "maxResults": 200}); err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, stdout.Bytes())
if payload["scope"] != "all" {
t.Fatalf("scope = %#v", payload["scope"])
}
if comments := fileCommentListPayload(fileCommentPage{}, "all")["comments"].([]map[string]any); len(comments) != 0 {
t.Fatalf("nil comments projection = %#v", comments)
}
}
@@ -21,7 +21,9 @@ import (
"io"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -39,11 +41,16 @@ type imReadResultCaller struct {
results map[string]*edition.ToolResult
errors map[string]error
calls []imReadResultCall
args map[string]any
dryRun bool
}
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName})
c.args = map[string]any{}
for k, v := range args {
c.args[k] = v
}
if err := c.errors[toolName]; err != nil {
return nil, err
}
@@ -203,6 +210,37 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg
}
}
func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) {
payload := `{"result":{"messages":[],"hasMore":false}}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
loc := shanghaiLocation()
before := time.Now().In(loc)
_, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--limit", "50")
after := time.Now().In(loc)
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
}
if got := caller.args["forward"]; got != false {
t.Fatalf("forward = %#v, want false when --time is omitted", got)
}
timeRaw, ok := caller.args["time"].(string)
if !ok || strings.TrimSpace(timeRaw) == "" {
t.Fatalf("time arg = %#v, want generated current time", caller.args["time"])
}
gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc)
if err != nil {
t.Fatalf("parse generated time %q: %v", timeRaw, err)
}
if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) {
t.Fatalf("time = %s, want between %s and %s", gotTime, before, after)
}
}
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
+2 -2
View File
@@ -611,7 +611,7 @@ func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
got := applyDriveListLatest(items, 10, false)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
@@ -674,7 +674,7 @@ func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
@@ -322,7 +322,7 @@ func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
}
got := applyDriveListLatest(items, 1)
got := applyDriveListLatest(items, 1, false)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
+82 -5
View File
@@ -1,10 +1,13 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -1322,10 +1325,13 @@ ORG 类型授权不会出现在查询结果中。`,
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
禁止在客户端自行过滤。`,
Example: ` dws wiki feed list --workspace <workspaceId>
dws wiki feed list --workspace <workspaceId> --limit 10
dws wiki feed list --workspace <workspaceId> --exclude-file
dws wiki feed list --workspace <workspaceId> --limit 10 --exclude-file
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
@@ -1345,7 +1351,19 @@ ORG 类型授权不会出现在查询结果中。`,
v, _ := cmd.Flags().GetBool("exclude-file")
toolArgs["excludeFile"] = v
}
return callMCPTool("list_workspace_feeds", toolArgs)
// 调用后对 feeds[].time 毫秒时间戳做本地化格式化,
// 使 Agent 直接看到可读时间,无需自行转换
text, err := callMCPToolReturnText(context.Background(), "list_workspace_feeds", toolArgs)
if err != nil {
return err
}
// 保持与共享 dispatcher (callMCPToolInternalOpts) 一致的输出契约:
// 仅 --format json 做时间/标签增强,raw、table 及其他格式输出原始 MCP 文本
if deps.Caller.Format() != "json" {
deps.Out.PrintRaw(text)
return nil
}
return deps.Out.PrintJSON(formatFeedTime(text))
},
}
DeclareLeafMetadata(feedListCmd, LeafSpec{
@@ -1391,9 +1409,9 @@ ORG 类型授权不会出现在查询结果中。`,
},
})
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
feedListCmd.Flags().String("workspace-id", "", "")
_ = feedListCmd.Flags().MarkHidden("workspace-id")
RegisterCrossProductAliases(feedListCmd)
@@ -1430,3 +1448,62 @@ ORG 类型授权不会出现在查询结果中。`,
return root
}
// beijingLoc 用于将时间戳格式化为北京时间(UTC+8)
var beijingLoc = time.FixedZone("CST", 8*3600)
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
// 解析失败时原样返回,不阻断输出。
func formatFeedTime(raw string) any {
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return raw
}
feeds, ok := result["feeds"].([]any)
if !ok {
return result
}
for _, f := range feeds {
item, ok := f.(map[string]any)
if !ok {
continue
}
ms, ok := item["time"].(float64)
if !ok || ms <= 0 {
continue
}
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
enrichFeedFields(item)
}
return result
}
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
func enrichFeedFields(item map[string]any) {
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
if typeNum, ok := item["type"].(float64); ok {
if label, known := feedTypeLabels[int(typeNum)]; known {
item["typeLabel"] = label
}
}
}
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
// 与 lippi-combo OpenFeedItemDTO.type 枚举对齐。
var feedTypeLabels = map[int]string{
0: "创建文档",
1: "更新文档",
2: "评论文档",
3: "点赞文档",
4: "加入团队空间",
5: "表格选区数据变更",
6: "更新 office 文件",
7: "上传普通文件",
8: "上传媒体文件",
9: "上传文件夹",
10: "上传文件夹 V2",
11: "加入团队",
12: "创建知识库",
}
+582
View File
@@ -0,0 +1,582 @@
package helpers
import (
"bytes"
"encoding/json"
"io"
"os"
"strings"
"testing"
)
// ---------- formatFeedTime unit tests ----------
func TestCrossPlatformCoverageFormatFeedTimeInvalidJSON(t *testing.T) {
// JSON 解析失败 → 原样返回原始字符串
raw := "not-json"
got := formatFeedTime(raw)
s, ok := got.(string)
if !ok || s != raw {
t.Fatalf("formatFeedTime(invalid) = %v (%T), want raw string %q", got, got, raw)
}
}
func TestCrossPlatformCoverageFormatFeedTimeEmptyInput(t *testing.T) {
// 空字符串 → JSON 解析失败 → 原样返回
got := formatFeedTime("")
s, ok := got.(string)
if !ok || s != "" {
t.Fatalf("formatFeedTime(\"\") = %v (%T), want \"\"", got, got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNoFeedsKey(t *testing.T) {
// 合法 JSON 但没有 feeds 字段 → 返回解析后的 map
raw := `{"status":"ok","count":0}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(no feeds) = %T, want map[string]any", got)
}
if m["status"] != "ok" {
t.Fatalf("status = %v, want ok", m["status"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNotArray(t *testing.T) {
// feeds 不是数组 → 返回解析后的 map
raw := `{"feeds":"unexpected-string"}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds string) = %T, want map[string]any", got)
}
if m["feeds"] != "unexpected-string" {
t.Fatalf("feeds = %v, want unexpected-string", m["feeds"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNull(t *testing.T) {
// feeds 为 null → 类型断言失败 → 返回解析后的 map
raw := `{"feeds":null}`
got := formatFeedTime(raw)
_, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds null) = %T, want map[string]any", got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNonMapItem(t *testing.T) {
// feeds 数组元素不是 object → continue 跳过
raw := `{"feeds":["string-item", 42, null]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 3 {
t.Fatalf("feeds length = %d, want 3", len(feeds))
}
}
func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
// time 字段缺失或不是 float64 或 <= 0 → 跳过格式化
raw := `{"feeds":[
{"id":"a"},
{"id":"b","time":"not-a-number"},
{"id":"c","time":0},
{"id":"d","time":-1000}
]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
for i, f := range feeds {
item := f.(map[string]any)
if _, hasTimeMs := item["timeMs"]; hasTimeMs {
t.Fatalf("feed[%d] should not have timeMs, got %v", i, item)
}
}
}
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
tsMs := float64(1750067400000)
raw := buildFeedJSON(t, []map[string]any{
{"time": tsMs, "type": float64(1), "id": "f1"},
})
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 1 {
t.Fatalf("feeds length = %d, want 1", len(feeds))
}
item := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳不变
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
}
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
timeStr, ok := item["timeFormatted"].(string)
if !ok {
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
}
if timeStr != "2025-06-16 17:50" {
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
}
// typeLabel 被填充
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
// 多个 feed,混合有效/无效时间戳
ts1 := float64(1750067400000)
ts2 := float64(1700000000000)
raw := buildFeedJSON(t, []map[string]any{
{"time": ts1, "type": float64(0), "id": "f1"},
{"time": ts2, "type": float64(12), "id": "f2"},
{"id": "f3"}, // 没有 time → 跳过
})
got := formatFeedTime(raw)
m := got.(map[string]any)
feeds := m["feeds"].([]any)
// 前两个有时间戳
for i := 0; i < 2; i++ {
item := feeds[i].(map[string]any)
if _, ok := item["time"].(float64); !ok {
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
}
if _, ok := item["timeFormatted"].(string); !ok {
t.Fatalf("feed[%d] timeFormatted not string", i)
}
}
// 第三个没有时间戳
item3 := feeds[2].(map[string]any)
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
t.Fatalf("feed[2] should not have timeFormatted")
}
}
// ---------- enrichFeedFields unit tests ----------
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
item := map[string]any{
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1"},
"id": "f1",
"type": float64(1),
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := item["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
// 已知 type → 添加 typeLabel
for typeNum, expectedLabel := range feedTypeLabels {
item := map[string]any{"type": float64(typeNum)}
enrichFeedFields(item)
if item["typeLabel"] != expectedLabel {
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
}
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
// 未知 type → 不添加 typeLabel
item := map[string]any{"type": float64(999)}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for unknown type 999")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
// type 不是 float64 → 不添加 typeLabel
item := map[string]any{"type": "string-type"}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for non-float type")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
// users 数组中的 user 对象所有字段保留不变
item := map[string]any{
"users": []any{
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
map[string]any{"nick": "Bob", "email": "bob@test"},
},
}
enrichFeedFields(item)
users := item["users"].([]any)
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://img" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
// users 数组中有非 map 元素 → 不 panic
item := map[string]any{
"users": []any{"not-a-map", 42, nil},
}
enrichFeedFields(item) // 不应 panic
users := item["users"].([]any)
if len(users) != 3 {
t.Fatalf("users length changed: %d", len(users))
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
// content 和 content.doc 所有字段保留不变
item := map[string]any{
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
"url": "http://example",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
}
enrichFeedFields(item)
content := item["content"].(map[string]any)
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["name"] != "测试文档" {
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
}
if doc["docKey"] != "dk123" {
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
// 没有 content / users / type → 所有字段保留
item := map[string]any{
"id": "f1",
"parentDoc": map[string]any{"x": 1},
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
// 空 map → 不 panic
item := map[string]any{}
enrichFeedFields(item)
if len(item) != 0 {
t.Fatalf("empty item changed: %v", item)
}
}
// ---------- RunE integration: feed list with formatFeedTime ----------
func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
// 通过 scriptedToolCaller 返回真实结构的 feed JSON,验证 RunE 中
// callMCPToolReturnText → formatFeedTime → PrintJSON 完整路径(仅 --format json)
caller := &scriptedToolCaller{format: "json"}
ts := float64(1750067400000)
feedPayload := map[string]any{
"feeds": []any{
map[string]any{
"time": ts,
"type": float64(0),
"id": "feed1",
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1", "avatarUrl": "http://img"},
"users": []any{
map[string]any{"nick": "Alice", "userId": "u1", "avatarUrl": "http://avatar"},
},
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
},
map[string]any{
"time": float64(1700000000000),
"type": float64(7),
"id": "feed2",
},
},
}
payloadBytes, err := json.Marshal(feedPayload)
if err != nil {
t.Fatal(err)
}
caller.steps = []scriptedToolStep{{text: string(payloadBytes)}}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--exclude-file", "--format", "json"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 验证输出是合法 JSON 且包含格式化后的 feed
var parsed map[string]any
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, out.String())
}
feeds, ok := parsed["feeds"].([]any)
if !ok || len(feeds) != 2 {
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
}
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
f0 := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳
if f0["time"] != ts {
t.Fatalf("time = %v, want %v", f0["time"], ts)
}
// timeFormatted 被填充
if _, ok := f0["timeFormatted"].(string); !ok {
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
}
// 所有原始字段保留(不删除任何字段)
if _, ok := f0["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := f0["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if f0["typeLabel"] != "创建文档" {
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
}
// users 保留验证:avatarUrl / userId 均保留
if users, ok := f0["users"].([]any); ok {
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://avatar" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
// content 结构保留验证
if content, ok := f0["content"].(map[string]any); ok {
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["docKey"] != "dk123" {
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
}
}
}
func TestCrossPlatformCoverageWikiFeedListRawFormat(t *testing.T) {
// --format raw: 输出原始 MCP 文本,不经过 formatFeedTime 后处理
rawPayload := `{"feeds":[{"time":1750067400000,"type":1}]}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "raw"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// raw 输出应包含原始文本,不经过 formatFeedTime 格式化
got := out.String()
if !bytes.Contains([]byte(got), []byte(`"feeds"`)) {
t.Fatalf("raw output should contain original payload, got: %s", got)
}
}
func TestCrossPlatformCoverageWikiFeedListTableFormatMatchesDispatcher(t *testing.T) {
// --format table 回归:与共享 dispatcher (callMCPToolInternalOpts) 契约一致,
// 输出原始紧凑 MCP 文本,不做 pretty 化,也不注入 timeFormatted / typeLabel
rawPayload := `{"feeds":[{"time":1750067400000,"type":1,"id":"f1"}]}`
caller := &scriptedToolCaller{
format: "table",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "table"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 与共享 dispatcher 一致:输出 = 原始 MCP 文本原文
if got := strings.TrimSpace(out.String()); got != rawPayload {
t.Fatalf("table output should equal raw MCP payload,\ngot: %s\nwant: %s", got, rawPayload)
}
// 不注入任何增强字段
for _, injected := range []string{"timeFormatted", "typeLabel"} {
if strings.Contains(out.String(), injected) {
t.Fatalf("table output should not inject %s, got: %s", injected, out.String())
}
}
}
func TestCrossPlatformCoverageWikiFeedListEmptyResponse(t *testing.T) {
// MCP 返回空结果 → formatFeedTime 处理空字符串 → 不报错
caller := &scriptedToolCaller{} // 无 steps → 空结果
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
}
// ---------- helpers ----------
func buildFeedJSON(t *testing.T, feeds []map[string]any) string {
t.Helper()
payload := map[string]any{"feeds": feeds}
b, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
+17 -4
View File
@@ -324,11 +324,23 @@ func (m FlagMigration) validate() error {
if m.Canonical.After.Hidden {
return fmt.Errorf("canonical flag must remain visible")
}
if !m.Canonical.After.Required {
return fmt.Errorf("canonical flag must be required after migration")
// Requiredness belongs to the one logical parameter. The hidden legacy
// spelling must not remain independently required, while the canonical
// spelling inherits the exact before-state contract. An already-visible
// canonical flag cannot change requiredness; an existing hidden canonical
// placeholder may inherit it when promoted to the public spelling.
if m.Legacy.After.Required {
return fmt.Errorf("legacy compatibility alias must not remain independently required after migration")
}
if m.Canonical.Before.Present && m.Canonical.Before.Required {
return fmt.Errorf("canonical flag must be absent or optional before migration")
if m.Legacy.Before.Required != m.Canonical.After.Required {
return fmt.Errorf(
"flag requiredness must be preserved from legacy before to canonical after",
)
}
if m.Canonical.Before.Present &&
!m.Canonical.Before.Hidden &&
m.Canonical.Before.Required != m.Canonical.After.Required {
return fmt.Errorf("canonical flag requiredness must remain unchanged when already present")
}
if m.Legacy.After.AliasOf != m.Canonical.Name {
return fmt.Errorf(
@@ -682,6 +694,7 @@ func flagMigrationAuthorizesChange(
return true
}
if migration.Canonical.Before.Present &&
migration.Canonical.Before.Hidden &&
!migration.Canonical.Before.Required &&
migration.Canonical.After.Required &&
change.Kind == "flag_became_required" {

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