Compare commits

...
Author SHA1 Message Date
john 1920552ab0 Merge pull request #895 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.4
docs: seal v1.0.57-beta.4 changelog
2026-08-06 16:21:44 +08:00
chichuan f1b5330a4e docs: seal v1.0.57-beta.4 changelog 2026-08-06 16:18:22 +08:00
github-actions[bot] f9ccff963f Merge pull request #887 from DingTalk-Real-AI/feat/multi-skill-framework-align
feat(skills+schema): multi-skill fold, ding ParamDecl, retire discovery cache
2026-08-06 16:06:54 +08:00
玉澜andCursor 5cbc11d58f Merge branch 'main' into feat/multi-skill-framework-align
Resolve CHANGELOG conflict: keep Unreleased recovery deprecation and
main's v1.0.57 stable release section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:48:39 +08:00
玉澜andCursor a0a995cfee ci: retrigger full CI after missed pull_request run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:45:28 +08:00
玉澜andCursor 354c4546d8 docs(mail): align share-to-chat help with --yes hard gate
Update Long and --yes flag text to match the upfront confirmation gate
and automatic sign retry after --yes, consistent with thread trash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:39:25 +08:00
玉澜andCursor 3ab22071fb fix(mail): hard-gate share-to-chat with --yes before MCP
Add explicit confirmation_required gate before any share_message_to_chat
call so piped stdin or direct server success cannot bypass --yes. Keep
sign retry after confirmation and add regression tests for zero-call deny,
sign retry, and direct-success paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:37:09 +08:00
github-actions[bot] a34f46794e Merge pull request #893 from DingTalk-Real-AI/codex/changelog-v1.0.57
docs: seal v1.0.57 changelog
2026-08-06 15:35:16 +08:00
chichuan f0b0bdbe48 docs: seal v1.0.57 changelog 2026-08-06 15:33:29 +08:00
github-actions[bot] 1fe019994d chore: update beta formula for v1.0.57-beta.3 [skip ci] 2026-08-06 07:27:42 +00:00
玉澜andCursor 85cb41423b revert(schema-compat): drop residual property-remap assertion from ding backfill
Keep schema-compat tests aligned with main after removing the ding
property-correction allowlist; the hard-fail case is already covered elsewhere.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:26:45 +08:00
玉澜andCursor 02ccd9d134 Merge branch 'main' into feat/multi-skill-framework-align
Resolve PR #887 base drift so CI merge-revision check matches event.base.sha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:10:10 +08:00
chichuan c280b19568 Merge pull request #892 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.3
docs: seal v1.0.57-beta.3 changelog
2026-08-06 15:06:55 +08:00
chichuan bb5065410e docs: seal v1.0.57-beta.3 changelog 2026-08-06 14:59:39 +08:00
玉澜 0353215b1d Revert "declare(ding): semantic ParamDecl backfill for schema inference residuals"
This reverts commit 969292a8d7.
2026-08-06 14:52:34 +08:00
github-actions[bot] 3c7ed03bd6 Merge pull request #880 from DingTalk-Real-AI/codex/doc-shortcut-final
feat(doc): add reviewed document shortcuts
2026-08-06 14:36:21 +08:00
玉澜andCursor a1f8ecb7f0 test(coverage): cover empty allowlist path in filterBlocksByFiles
Hit the nil-allowlist early return so the platform changed-statement gate
reaches 100% after shared-file overall baseline filtering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:33:36 +08:00
玉澜andCursor a34ca5a137 test(ci): allow 0.1pp overall coverage tolerance in workflow contract
Align the Code Admission workflow contract with the shared-file overall
non-regression tolerance needed after deleting fully covered packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:18:48 +08:00
玉澜andCursor 7887b9473f chore(cli): extend interface baseline for visible cache shim
Merge of the Deprecated cache refresh surface reintroduced a public root
command; refresh the CLI interface baseline so cli-smoke stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:12:10 +08:00
玉澜andCursor fd3c82aa91 fix(coverage): compare overall on shared files and cover compact edges
Deleting 100%-covered packages such as recovery falsely regressed overall
percent against merge-base. Baseline overall now uses only files still
present in the candidate profile, with a 0.1pp CI tolerance. Also exercise
stripSchemaValueCompact nested map/slice branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:06:52 +08:00
玉澜andCursor 9266632694 Merge branch 'fix/skill-doc-quickwins' (#869)
Absorb skill-doc quickwins intent while keeping the multi-skill fold:
standalone hrbrain/markdown/pat/profile/skill packages stay in misc,
and markdown routing remains misc-targeted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:04:58 +08:00
玉澜 b0cbcd7a04 Merge branch 'fix/cli-missing-surfaces-from-eval' (#868)
Bring missing CLI surfaces from wukong cli_to_mcp eval into multi-skill align.
2026-08-06 13:02:48 +08:00
玉澜andCursor 6251117bd0 fix(cli): keep visible Deprecated cache refresh shim
Restore dws cache {refresh,status,clean} as a successful no-op
compat surface so historical scripts/agents keep working after
static-endpoint delivery. Deprecated leaves stay out of Schema via
IsAvailableCommand without schema exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:02:39 +08:00
玉澜andCursor 566803c431 fix(skills): drop invalid dws command prose in misc refs
Rewrite incomplete `dws devapp +` and non-product `dws finance` mentions
so skill-command-integrity no longer treats them as executable paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:40:17 +08:00
玉澜andCursor d6848da60b chore(cli): extend interface baseline for recovery root
Visible Deprecated recovery stubs are part of the public root command
tree; refresh the CLI interface baseline so cli-smoke stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:33:47 +08:00
Dennis 5f5d7ee21e fix(localio): harden local download publication 2026-08-06 12:26:50 +08:00
玉澜andCursor 53169a41af fix(policy): split schema projection --jq=/--fields= cases
Cover each equals-form flag in its own switch case so the platform
changed-statement coverage gate reliably hits both branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:26:47 +08:00
玉澜andCursor e90d5bac68 test: close platform coverage gaps for recovery and schema edges
Hit Encode/Fprint failure paths, compact projection branches, authActions,
reviewed property corrections, and schemaProjectionIssue --jq=/--fields=
so the changed-statement coverage gate reaches 100%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:22:04 +08:00
玉澜andCursor d725bdbaa3 fix(schema): drop stale recovery exclusions
Deprecated recovery leaves are not public schema leaves
(IsAvailableCommand=false), so listing them as exclusions fails
completeness with stale exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:00:33 +08:00
玉澜 9aea8c0b5c Merge chore/retire-mcp-schema-candidates (#882).
Bring MCP pin candidate retirement into the multi-skill align PR.
2026-08-06 11:54:12 +08:00
玉澜andCursor f54b964d62 fix(cli): keep visible Deprecated recovery stub
Drop Hidden so authoritative interface integrity still passes, restore
the CI historical command gate, and keep the unsupported notice without
Skill guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:48:31 +08:00
玉澜andCursor 11fbeb4851 fix(cli): hide dws recovery with unsupported notice
Keep a Hidden compatibility shim that returns 不再支持 for plan/
execute/finalize, so this release stops supporting the surface while a
later release can delete the shim entirely.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:41:56 +08:00
玉澜andCursor 2b1f38edae ci: drop historical interface command gate
Allow intentional removal of public commands (e.g. dws recovery)
without compatibility stubs. Keep Schema and skill-command checks
in the Interface Integrity job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:36:29 +08:00
Dennis 2f3797c1f6 fix(localio): enforce secure download client 2026-08-06 11:31:01 +08:00
玉澜andCursor a0b0d98180 drop recipes/shared intermediate dir under multi skills
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:01:07 +08:00
Dennis 990c85d36b fix(localio): strip headers on cross-origin redirects 2026-08-06 11:00:44 +08:00
玉澜andCursor b742343937 restore per-product conventions dirs; rename _common to recipes/shared only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:56:10 +08:00
玉澜andCursor 657df05d40 Reshape multi shared recipe dirs for agent-friendly paths, content unchanged.
Move dingtalk-shared best_practices/_common into references/recipes/,
drop duplicated product _common/conventions copies, and retarget links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:53:28 +08:00
玉澜andCursor 86f2b14449 docs: drop multi skill experimental banners
Remove EXPERIMENTAL/Preview banners from skill setup, install scripts, README, and misc references so multi mode is no longer framed as unstable preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:46:42 +08:00
玉澜andCursor 86014c97cf fix(makefile): keep skill content/command checks out of policy
Do not expand the default policy gate with mono-multi or skill-commands;
leave them as optional make targets only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:35:20 +08:00
玉澜andCursor 94f3bba504 Merge declare/semantic-param-backfill into multi-skill align.
Bring ding ParamDecl backfill, schema agent-view bounds, and discovery
cache CLI/doctor retirement onto the multi-skill framework branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:31:40 +08:00
玉澜andCursor 339eaa4b1b Drop discovery-cache skill guidance and doctor cache check.
Prefer schema --compact in agent docs, remove服务发现/cache teaching,
and stop doctor from reporting a no-op cache health item.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:28:20 +08:00
玉澜andCursor 96774e6e23 Remove retired discovery cache CLI and doctor cache check.
Drop the no-op dws cache stubs and the doctor cache health item, and
scrub skill/AGENTS guidance that still pointed agents at them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:28:05 +08:00
Dennis 1f77ba31f3 fix(localio): disable proxies for secure downloads 2026-08-06 10:26:04 +08:00
dxy704330469 eb0bd69b82 feat(doc): add reviewed document shortcuts
- add 45 public document shortcuts and 2 reviewed expert-only paths
- preserve six historical command and Schema identities alongside canonical leaves
- add safe local download primitives and document access/share orchestration
- keep comment create/reply confirmation backward-compatible
- ensure grant-and-share upgrades insufficient roles before messaging
- return non-zero partial/failure message ledgers and structured partial-write recovery metadata
- enumerate every selection candidate and use rune-safe Unicode keyword contexts
- assert zero-call confirmation boundaries for destructive shortcuts

Validation:
- full Go test suite and repository policy
- real DingTalk E2E for 34 canonical shortcuts, all 8 compatibility-affected entries, READER-to-EDITOR grant-and-share upgrade, and same-block selection ambiguity with zero comment writes
- command compatibility across 1,221 historical nodes and complete Schema compatibility
- 1,152 Agent examples including 62 real Cobra dry-runs
- 100% changed-code coverage across 1,260 executable statements
2026-08-06 09:56:58 +08:00
玉澜 c1d90672a8 test(schema): drop compact leaf size ceiling 2026-08-06 00:56:13 +08:00
玉澜 3d2d287723 feat(schema): bound agent views and prevent instruction drift 2026-08-06 00:49:31 +08:00
玉澜andCursor 969292a8d7 declare(ding): semantic ParamDecl backfill for schema inference residuals
Complete ding leaf Property/Required from Execute CallMCP keys and live
help semantics, and allowlist the four inference→declare property remaps
so schema-compat does not freeze wrong camelCase flag names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:19:19 +08:00
github-actions[bot] 4bcf71fb9e Merge pull request #881 from Anonymity-0/feat/chat-reply-mentions
feat(chat): support mentions in message replies
2026-08-05 23:10:42 +08:00
玉澜andCursor ac610f2d24 fix(skills): remove stale conference product routing
No conference skill exists; stop linking conference.md / routing to
conference, and teach CLI-unsupported + DingTalk client instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:07:32 +08:00
玉澜andCursor 98f45cfe23 Retire dead MCP pin candidates from Schema parameter assembly.
Production already ships an empty pin; remove the leftover mcp_metadata
candidate path so parameter resolution only uses declare/Cobra sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:45:33 +08:00
玉澜andCursor fbf97ce402 feat(skills): fold long-tail skills into misc; rename dws-shared
Host hrbrain, markdown, pat, and profile under dingtalk-misc, retire
their standalone packages, and rename dws-shared to dingtalk-shared
across live paths, coverage, installers, and policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:30:08 +08:00
玉澜andCursor f5b029b1a5 feat(skills): wire misc routing and coverage for folded event
Point coverage, installers, IM skill-chain, and shared routing at
dingtalk-misc references after retiring the standalone event package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:18:21 +08:00
玉澜andCursor 75f92cf546 feat(skills): fold dingtalk-event into dingtalk-misc
Host personal IM event docs under misc like other long-tail products, and
retire the standalone multi skill package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:18:18 +08:00
玉澜andCursor 6d485f47eb feat(skills): wire misc routing and coverage for folded dev/skill
Point coverage, shortcut generation, installers, and shared routing at
dingtalk-misc references after retiring the standalone packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:02:11 +08:00
玉澜andCursor 6651a162c5 feat(skills): fold dingtalk-dev and dingtalk-skill into dingtalk-misc
Host open-platform app docs and skill-market commands under misc like
other long-tail products, and retire the standalone multi skill packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:01:57 +08:00
玉澜 a9aa39c3e2 Revert "fix(skills): correct deprecated and nonexistent command teaching"
This reverts commit b0cd419f28.
2026-08-05 21:37:37 +08:00
前津 545ee17316 fix(chat): add missing reply mention placeholders 2026-08-05 21:23:12 +08:00
前津 0c62938f74 feat(chat): support mentions in message replies 2026-08-05 21:23:12 +08:00
玉澜andCursor b0cd419f28 fix(skills): correct deprecated and nonexistent command teaching
Align mono/multi minutes/doc/conference skill facts with live CLI: prefer
--limit/--cursor, drop false participants claims, replace deprecated doc
search, and mark conference as unsupported without dead conference.md links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:13:39 +08:00
玉澜andCursor aa487002b7 fix(i18n/docs): drop recovery locale strings and close audit nits.
Sync transport post-recovery hints into en/zh locales, refresh skill QA docs for Phase 1–3/4B, and remove the dead internal/recovery CI high-risk path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:26:41 +08:00
玉澜andCursor 941bf01e30 Remove unused dws recovery CLI and continue multi-skill content framework.
Drop the recovery package/commands and related Schema/skill teaching so agents
stop being steered at a dead surface, while keeping mono↔multi content QA work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:23:37 +08:00
玉澜andCursor b439c5fa09 docs(skill): add mono↔multi content QA track to align plan
Specify coverage/structure/drift gates against mono, inventory existing
skill policy tests, and extend the §7 checklist for the QA track.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:23:01 +08:00
玉澜andCursor 67250a9da5 docs(skill): limit align plan to content framework only
Defer install/upgrade behavior and cherry-picks to a follow-up branch;
keep this branch on multi/mono content layout and content contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:57 +08:00
玉澜andCursor 70243acb95 docs(skill): narrow wukong align plan to skill framework
Limit scope to skill trees and skill install/setup/upgrade framework;
defer non-skill CLI, client pipelines, and full installer rewrites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:53 +08:00
玉澜andCursor 6cce7fdbd8 docs(skill): add multi-skill vs wukong align plan
Capture inventory, port/adapt/reject decisions, and phased work before any
framework implementation on a main-based branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:48 +08:00
github-actions[bot] 45b43e52bb chore: update beta formula for v1.0.57-beta.2 [skip ci] 2026-08-05 11:49:09 +00:00
chichuan 95a5cc42ce Merge pull request #879 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.2
docs: seal v1.0.57-beta.2 changelog
2026-08-05 19:36:25 +08:00
chichuan fec750b09e docs: remove duplicate beta.2 changelog entry 2026-08-05 19:26:27 +08:00
chichuan ddd5f15b91 docs: seal v1.0.57-beta.2 changelog 2026-08-05 19:19:49 +08:00
github-actions[bot] db50be868b Merge pull request #876 from DingTalk-Real-AI/codex/restore-chat-im-compat
fix(chat): restore stable send and history compatibility
2026-08-05 19:13:50 +08:00
Dennis a6220d7d8b fix(chat): preserve migration hints with legacy flags 2026-08-05 18:19:16 +08:00
Dennis 81bf0d2a6b test(coverage): stabilize drive worker cancellation branch 2026-08-05 18:05:54 +08:00
Dennis f3a95d34a3 fix(chat): restore stable send and history compatibility 2026-08-05 17:32:12 +08:00
玉澜andCursor 2ea5acc2a3 fix(helpers): align PR868 whiteboard coverage with main API
After merging #861, use prepareWhiteboardCard and --yes so coverage
tests compile and match fail-closed confirmation + soft pending verify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:07:19 +08:00
玉澜andCursor 5e1bac51e5 Merge origin/main into fix/cli-missing-surfaces-from-eval
Keep main/#861 fail-closed whiteboard; retain #868 missing surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:02:27 +08:00
chichuan a37e6e6847 Merge pull request #875 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.1
docs: seal v1.0.57-beta.1 changelog
2026-08-05 16:51:42 +08:00
chichuan 0ceb96c745 docs: seal v1.0.57-beta.1 changelog 2026-08-05 16:47:10 +08:00
github-actions[bot] 114503d52f Merge pull request #872 from lifeihong/feat/addUpdateUserOwnessV2A84934011
feat(contact): add update-ownness command for user personal status
2026-08-05 08:36:50 +00:00
chichuan 9de1c9c304 Merge branch 'main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:25:02 +08:00
github-actions[bot] 840e1d665f Merge pull request #861 from DingTalk-Real-AI/codex/sync-wukong-whiteboard
feat: add document whiteboard workflows
2026-08-05 16:17:14 +08:00
昕卉 f362c8c2a4 Merge remote-tracking branch 'upstream/main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:06:52 +08:00
chichuan e73a1556ce Merge latest main into codex/sync-wukong-whiteboard
冲突仅在 skills/mono/SKILL.md 的意图路由表,两侧改动正交,均保留:
- 本分支新增的 whiteboard 路由行
- main 把 event 行拆成 `event +listen-im` / `event consume` 的新表述
(下文「优先由一个 dws event +listen-im 进程表达目标」已是 main 版本,
保留旧 event 行会自相矛盾)
2026-08-05 15:59:17 +08:00
昕卉 186f2fa474 test(contact): add update-ownness tests and align confirmation with framework gate 2026-08-05 15:39:22 +08:00
github-actions[bot] d91a93c43b Merge pull request #860 from DingTalk-Real-AI/codex/multi-im-optimization
feat(im): harden Multi IM and publish complete Chat Schema
2026-08-05 15:28:29 +08:00
chichuan 08254e2a36 fix(whiteboard): fail closed when insert verification query fails
回查循环原先吞掉全部 queryErr,鉴权失败、MCP 错误与 JSONML 解析失败都
退化成 soft success 返回 whiteboardId: null,Agent 会把硬失败误判成最终
一致性并带着空 partId 继续调用 whiteboard query/update。

- 引入 errWhiteboardBlockPending sentinel,只有「块暂不可见」允许重试;
  其余错误立即返回,并把已插入的 blockId 带进错误消息供复原
- queryWhiteboardCardNode 严格校验 blocks 字段(缺失 / 非数组均为协议
  错误),避免畸形响应伪装成「块暂不可见」
- --ref-block 与 --parent-block、--where 与 --parent-block 显式互斥,
  锚点组装改用 else if 让单一定位分支在代码上自证
- 补全 mono / multi 两份 recipes.md 被截断的开篇句
- doc 根命令的命令结构清单补上 whiteboard insert 与 media upload/download
- 新增 3 个回归测试覆盖 fail-closed、soft success 与锚点互斥
2026-08-05 15:14:27 +08:00
Dennis e2c15fe9c8 fix(schema): reject breaking constraint expansion 2026-08-05 14:56:18 +08:00
Dennis 9fdf0d2cb3 fix(im): preserve incomplete read failures 2026-08-05 14:27:53 +08:00
Dennis b6325a4f8a fix(im): harden user resolution and broadcast lookup 2026-08-05 13:57:27 +08:00
昕卉 fdd9e189d6 add update ownness 2026-08-05 13:32:23 +08:00
玉澜andCursor 65ce71b8d4 fix(helpers): avoid race on markdown diff compute seam
Capture runMarkdownUnifiedDiff/diffJSONMarshalIndent before spawning the
compute goroutine so testseam restores cannot race a late timeout path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:28:43 +08:00
玉澜andCursor 6bfcac4d54 test(helpers): use testseam for PR868 coverage seam swaps
Policy bans manual prev/t.Cleanup restores for package-var injection seams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:08:24 +08:00
玉澜andCursor f819566c63 fix(schema): treat drive download --version as mapping exclusion
Keep the add-only Wukong compat flag, but publish it as a CLI-local
polymorphic dispatch without a download_file property binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:58:47 +08:00
玉澜andCursor 10d9aa3058 test(helpers): raise PR868 changed-line coverage to 100%
Exercise markdown diff, mail export/share, drive latest/depth, whiteboard,
and diff-engine edges via TestCrossPlatformCoverage*; add small injectable
seams only where defensive branches are otherwise unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:50:00 +08:00
chichuan eebdf52da9 fix: classify local whiteboard example precondition 2026-08-05 12:28:46 +08:00
玉澜andCursor a0ca1fdb28 feat(helpers): add minutes missing surfaces and add-only flag aliases
Expose minutes hot-word delete, permission apply, and audio-memo list from
live MCP gaps; add hidden/cross-product flag aliases only (never remove),
with TestCrossPlatformCoverage coverage for the new surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:04:27 +08:00
chichuan 9eaee76a51 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-05 11:51:35 +08:00
Dennis 2588c711a7 Merge origin/main into codex/multi-im-optimization 2026-08-05 11:24:58 +08:00
玉澜andCursor 5a93f80daa fix(ci): align new-surface dry-run preview kinds with Schema
Whiteboard dry-run now stamps preview_kind=plan; mail export/share
drop [DRY-RUN] tags so plan evidence matches declared DryRunSpec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:21:38 +08:00
玉澜andCursor 8260cf7f53 fix(ci): drop --yes from mail share-to-chat examples
Schema Manual examples forbid confirmation bypass via --yes; also stub
whiteboardSleep in product example coverage so race CI does not hang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:05:54 +08:00
玉澜andCursor 9fd38d9b9d fix(skills): drop stale EXPERIMENTAL banners and dead skill links
Align leftover multi skill banners with the non-experimental wording,
retarget markdown routing off dingtalk-misc, and remove the retired
SAFETY_PREAMBLE_INJECT marker plus the missing extract_media_id.py refs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:44:01 +08:00
玉澜andCursor 58dfbc5b6e feat: sync missing open CLI surfaces from wukong cli_to_mcp gaps
Port calendar event instances, markdown diff, drive list --latest,
mail calendar/calendar-event/shared-with-me/export/share-to-chat, and
doc whiteboard insert so open edition matches documented Wukong test
command surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:39:56 +08:00
Dennis 01782cd9d7 test(profilectx): cover profile selector 2026-08-05 09:35:57 +08:00
Dennis ad4ed41559 Merge origin/main into codex/multi-im-optimization 2026-08-04 22:23:31 +08:00
Dennis 86f9054d5b test(chat): cover batch output failures 2026-08-04 22:13:20 +08:00
chichuan d7c28bcfef fix: complete whiteboard skill examples 2026-08-04 21:19:45 +08:00
chichuan 287b079c18 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 20:12:37 +08:00
Dennis c4d5139a50 fix(chat): fail batch sends on delivery errors 2026-08-04 19:43:22 +08:00
Dennis 867f20abec test(chat): cover unsafe group files on Windows 2026-08-04 19:22:58 +08:00
Dennis 7c07b29de5 ci: retain failed Windows coverage profiles 2026-08-04 19:06:50 +08:00
Dennis 6ee0df8a9c test(chat): cover Windows drive-relative paths 2026-08-04 18:53:24 +08:00
Dennis 33ceab6000 test(chat): keep platform replace alias coverage-neutral 2026-08-04 18:36:39 +08:00
Dennis 26b06fe0ff fix(chat): replace exports atomically on Windows 2026-08-04 18:18:08 +08:00
chichuan 50f8ade1d7 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 17:36:59 +08:00
chichuan b87cad1eb5 docs: fix whiteboard protocol table 2026-08-04 17:14:11 +08:00
chichuan 0f2eec145e docs: add OpenNodes V1 whiteboard protocol 2026-08-04 17:10:15 +08:00
Dennis 7a5582f4f9 fix(ci): make multi IM coverage platform-safe 2026-08-04 17:05:59 +08:00
Dennis f050fbdebc Merge latest main into codex/multi-im-optimization 2026-08-04 16:17:01 +08:00
Dennis e27dc9fe53 fix(schema): close chat runtime contract review 2026-08-04 16:09:54 +08:00
Dennis 85587b9b62 fix(schema): preserve published chat constraints 2026-08-04 15:31:10 +08:00
Dennis 10d93f310e fix(schema): restore chat compatibility gates 2026-08-04 15:18:04 +08:00
Dennis 99478c0060 feat(schema): publish complete chat shortcut catalog 2026-08-04 14:24:49 +08:00
chichuan 64c2e8544c test: complete whiteboard branch coverage 2026-08-04 14:11:29 +08:00
chichuan fc31fddd73 test: cover whiteboard error paths 2026-08-04 13:51:37 +08:00
chichuan 4298d0833b test: refresh CLI interface baseline 2026-08-04 11:45:05 +08:00
Dennis ee943d9b3f Merge remote-tracking branch 'origin/main' into codex/multi-im-optimization
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	test/mock_mcp/mock_mcp_smoke_test.go
2026-08-04 11:25:36 +08:00
chichuan 7e0957d9e8 feat: add document whiteboard workflows 2026-08-04 11:25:31 +08:00
Dennis a3c85a01a8 chore(im): sanitize pull request fixtures 2026-08-04 11:19:38 +08:00
Dennis 81f67c8d7b fix(im): classify failures and normalize chat targets 2026-08-04 11:06:35 +08:00
Dennis 30314311e4 fix(im): harden live audit contracts 2026-08-03 20:39:27 +08:00
Dennis 2a8c6c87cb feat(im): harden multi IM golden routes 2026-08-03 19:50:10 +08:00
Dennis 017258e5b4 feat(im): optimize Multi IM golden routes
Unify natural target resolution and message contracts, add deterministic IM event listening, streamline cold-start skills, and cover the flows with schema gates and end-to-end tests.
2026-08-03 14:54:19 +08:00
366 changed files with 29997 additions and 9672 deletions
+2 -2
View File
@@ -152,7 +152,6 @@ jobs:
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/recovery/') ||
filename.startsWith('internal/syncdata/') ||
filename.includes('/testdata/') ||
filename.startsWith('testdata/') ||
@@ -817,6 +816,7 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
@@ -1175,7 +1175,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0"
COVERAGE_OVERALL_TOLERANCE: "0.1"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
+15 -12
View File
@@ -302,9 +302,8 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -321,8 +320,10 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+122
View File
@@ -6,8 +6,130 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.4] - 2026-08-06
### Added
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
operations, with matching Schema and cross-platform coverage.
### Changed
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
stale Preview guidance, and reorganizes shared recipes and routing for more
predictable Agent selection.
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
focused on executable contract facts, retires stale MCP metadata candidates,
and teaches Agents to prefer `dws schema --compact` for bounded context.
### Deprecated
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
retiring their former recovery engine and dynamic discovery-cache behavior.
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
Skills no longer teach either retired workflow.
### Fixed
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
before the first remote write, while preserving the confirmed sign-retry
flow and covering both direct-success and retry responses.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
and compatibility improvements, plus the multi-skill framework alignment and
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
the final prerelease.
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
baseline as stable `v1.0.57` without adding post-beta product changes.
## [1.0.57-beta.3] - 2026-08-06
### Added
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
safe local downloads, content and history, review, media and style, and
document access/sharing workflows, while retaining reviewed compatibility
identities and confirmation safeguards for writes.
- **Mentions in chat replies** (#881) — `chat message reply` now supports
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
adding any required mention placeholders without changing existing send
behavior.
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
+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.56-beta.4"
version "1.0.57-beta.3"
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.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
end
def install
+9 -1
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -33,6 +33,7 @@ help:
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -87,6 +88,7 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@@ -126,6 +128,12 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
+11 -14
View File
@@ -71,9 +71,7 @@ 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** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
How to pick:
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -380,21 +378,21 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
### Agent Skills
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`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
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.
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -539,7 +536,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
</details>
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -636,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema --all # full export for CI/audit/baselines
```
+11 -14
View File
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
怎么选:
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
</details>
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -630,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
-1
View File
@@ -40,7 +40,6 @@
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
+1 -1
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
- Failure or degraded mode: inspect `internal/errors`
## Policy Checks
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
+11 -10
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+1 -1
View File
@@ -6,7 +6,7 @@
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
### macOS / Linux
+7 -6
View File
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
+386 -45
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-07-29T00:06:19.285348",
"count": 265,
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"results": [
{
"suite": "read",
@@ -485,7 +485,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -658,6 +658,16 @@
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
"command": "+chat-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
@@ -715,7 +725,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -1215,7 +1225,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"availability": "available"
},
{
@@ -1295,7 +1305,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"availability": "available"
},
{
@@ -1315,7 +1325,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"availability": "available"
},
{
@@ -1344,8 +1354,8 @@
"command": "+messages-send-card",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"availability": "available"
},
{
@@ -1415,7 +1425,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"availability": "available"
},
{
@@ -1435,7 +1445,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -1708,123 +1718,454 @@
"status": "real-ok"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+access-change",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-grant",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-revoke",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-delete",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以 clear 语义移除文档背景色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+checkpoint-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-create",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+comment-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+comment-reply",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+comment-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "更新指定评论正文与 mention。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create-from-template",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+doc-append",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+export",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+export-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+export-submit",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+fetch",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+find-doc",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+grant-and-share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+import",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+media-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-insert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-preview",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+resource-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+review",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+share-doc",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+template-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+template-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+version-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+version-save",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"availability": "available"
},
{
"suite": "write",
+105
View File
@@ -0,0 +1,105 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`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)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 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. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
+65
View File
@@ -0,0 +1,65 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
+272
View File
@@ -0,0 +1,272 @@
# 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)。*
+7 -9
View File
@@ -335,12 +335,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.AddCommand(command)
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+22 -11
View File
@@ -21,6 +21,10 @@ import (
"github.com/spf13/cobra"
)
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
Replacement string `json:"replacement,omitempty"`
}
// newCacheCommand keeps a visible Deprecated compatibility surface for
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
// Skills must not teach this path. Deprecated leaves are excluded from Schema
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
Short: "不再支持:服务发现缓存兼容入口",
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
return printCacheCompatNotice(cmd, "dws cache")
},
}
for _, name := range []string{"refresh", "status", "clean"} {
subName := name
sub := &cobra.Command{
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Use: subName,
Short: "不再支持:静态端点模式无需服务发现缓存",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, name)
return printCacheCompatNotice(cmd, "dws cache "+subName)
},
}
cmd.AddCommand(sub)
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
Command: command,
Message: cacheUnsupportedMessage,
Replacement: cacheReplacementHint,
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
+111
View File
@@ -0,0 +1,111 @@
// 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"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "cache")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
if group.IsAvailableCommand() {
t.Fatal("deprecated cache group must not be IsAvailableCommand")
}
for _, leaf := range []string{"refresh", "status", "clean"} {
cmd := mustFindCommand(t, root, "cache", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
if cmd.IsAvailableCommand() {
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
}
}
var out bytes.Buffer
cmd := NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var buf bytes.Buffer
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(&buf)
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
t.Fatalf("format=%q: %v", format, err)
}
text := buf.String()
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
t.Fatalf("format=%q missing notice:\n%s", format, text)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
}
}
}
for _, format := range []string{"pretty", "table"} {
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(failWriter{})
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
parent := newCacheCommand()
var parentOut bytes.Buffer
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.SetOut(&parentOut)
rootWrap.AddCommand(parent)
parent.SetOut(&parentOut)
if err := parent.RunE(parent, nil); err != nil {
t.Fatalf("cache parent RunE = %v, want nil success", err)
}
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
}
cache := newCacheCommand()
cache.SetOut(&bytes.Buffer{})
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
}
+7 -173
View File
@@ -32,7 +32,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"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/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
t.Fatal(err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
t.Fatal(err)
}
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
t.Fatalf("array attempts = %#v %v", got, err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
t.Fatal("malformed attempts succeeded")
}
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
t.Fatalf("legacy attempts = %#v %v", got, err)
}
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
t.Fatal("legacy attempts edge mismatch")
}
for _, args := range [][]string{
{"dws", "--debug", "doc", "get", "--node", "n"},
{"dws", "--format=json", "doc", "--", "ignored"},
{"dws", "--unknown", "value", "doc"},
} {
old := os.Args
os.Args = args
_ = currentCommandPath()
os.Args = old
}
for _, inv := range []executor.Invocation{
{LegacyPath: "legacy path"},
{CanonicalProduct: "doc", Tool: "get"},
{CanonicalProduct: "doc"},
{},
} {
old := os.Args
os.Args = []string{"dws"}
_ = runtimeCommandPath(inv)
os.Args = old
}
if cloneRecoveryArgs(nil) != nil {
t.Fatal("empty recovery args should clone to nil")
}
original := map[string]any{"x": 1}
clone := cloneRecoveryArgs(original)
clone["x"] = 2
if original["x"] != 1 {
t.Fatal("recovery args were not cloned")
}
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil recovery search = %#v", got)
}
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("blank recovery search = %#v", got)
}
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil recovery runtime call succeeded")
}
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("missing recovery endpoint error = %v", err)
} else {
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
t.Fatalf("missing recovery endpoint classification = %#v", err)
}
}
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
runtime := &recoveryRuntime{}
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
t.Fatalf("recovery endpoint override = %q %v", got, err)
}
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
t.Fatal("recovery token mismatch")
}
if toRecoveryToolResponse(nil) != nil {
t.Fatal("nil recovery response should stay nil")
}
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
if response == nil || !response.IsError || len(response.Content) != 1 {
t.Fatalf("recovery response = %#v", response)
}
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
for _, payload := range []map[string]any{
nil,
{"items": items},
{"data": map[string]any{"items": items}},
{"result": map[string]any{"items": items}},
} {
_ = parseDocSearchItemsFromMap(payload)
}
if toDocSearchItems("bad") != nil {
t.Fatal("non-list doc items accepted")
}
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
if got := parseDocSearchItems(result); len(got) != 1 {
t.Fatalf("block doc items = %#v", got)
}
if parseDocSearchItems(nil) != nil {
t.Fatal("nil doc result should be nil")
}
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
t.Fatal("doc search reranking mismatch")
}
}
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
t.Cleanup(func() {
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
_ = defaultPATMCPEndpoint()
}
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing recovery execution succeeded")
}
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("malformed recovery execution succeeded")
}
for name, body := range map[string]string{
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
} {
t.Run(name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
t.Fatalf("loaded execution = %#v %v", got, err)
}
})
}
}
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
}
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery MCP error succeeded")
}
server.Close()
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery network error succeeded")
}
}
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "status"); err != nil {
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
@@ -2168,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
multi := filepath.Join(t.TempDir(), "multi")
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -2193,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
t.Fatalf("multi setup = %q, %v", output, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
@@ -2220,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
for _, tc := range []struct {
include []string
exclude []string
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
}
}
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
_ = ensureMandatorySharedSkill(selected, all)
}
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
-1
View File
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
+1 -22
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
-25
View File
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
+1
View File
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
+295
View File
@@ -0,0 +1,295 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"consume", "list", "schema", "status", "stop"}
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
}
}
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
@@ -10,7 +10,7 @@ import (
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+14 -14
View File
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
// dingtalk-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
if !contains(all, "dingtalk-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
if contains(filtered, "dingtalk-shared") {
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
if !contains(final, "dingtalk-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
// 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 {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
// When the source has no dingtalk-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
if contains(final, "dingtalk-shared") {
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
}
}
@@ -29,6 +29,7 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
@@ -37,6 +38,7 @@ var paramAliasCompleteCommands = map[string][]string{
"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-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -119,6 +121,7 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -129,6 +132,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -169,7 +173,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -220,6 +224,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
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)
}
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -292,6 +297,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
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)
}
@@ -302,6 +308,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
}
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
+62 -440
View File
@@ -1,496 +1,118 @@
// 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"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(flags)
type recoveryCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
}
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
// returns an explicit unsupported notice. Skills must not teach this path.
func newRecoveryCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "recovery",
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Short: "不再支持:错误恢复辅助命令(兼容入口)",
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
return printRecoveryUnsupported(cmd, "dws recovery")
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "基于失败快照生成恢复计划",
Short: "不再支持:基于失败快照生成恢复计划",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery plan")
},
}
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
executeCmd := &cobra.Command{
Use: "execute",
Short: "生成面向 Agent 的恢复分析包",
Short: "不再支持:生成面向 Agent 的恢复分析包",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery execute")
},
}
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "回写恢复闭环结果",
Short: "不再支持:回写恢复闭环结果",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery finalize")
},
}
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
notice := recoveryCompatNotice{
Status: "unsupported",
Command: command,
Message: recoveryUnsupportedMessage,
}
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
return err
}
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
return apperrors.NewValidation(recoveryUnsupportedMessage)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
return err
}
return last, nil
return apperrors.NewValidation(recoveryUnsupportedMessage)
default:
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
return err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
transport: client,
flags: flags,
return apperrors.NewValidation(recoveryUnsupportedMessage)
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
@@ -0,0 +1,110 @@
// 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"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "recovery")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
for _, leaf := range []string{"plan", "execute", "finalize"} {
cmd := mustFindCommand(t, root, "recovery", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
wantFlags := []string{"event-id"}
switch leaf {
case "plan", "execute":
wantFlags = append(wantFlags, "last")
case "finalize":
wantFlags = append(wantFlags, "outcome", "execution-file")
}
for _, flag := range wantFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("recovery %s missing --%s", leaf, flag)
}
}
for _, child := range newRecoveryCommand().Commands() {
if child.Name() != leaf {
continue
}
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
}
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var out bytes.Buffer
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(&out)
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
err := printRecoveryUnsupported(sub, "dws recovery plan")
if err == nil {
t.Fatalf("format=%q returned nil error", format)
}
typed, ok := err.(*apperrors.Error)
if !ok || typed.Category != apperrors.CategoryValidation {
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
}
got := out.String() + err.Error()
if !strings.Contains(got, "不再支持") {
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
}
}
}
for _, format := range []string{"json", "pretty", "table"} {
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(failWriter{})
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
}
captureRuntimeFailure(executor.Invocation{}, nil, nil)
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
var errWriteFailed = errors.New("write failed")
@@ -1,151 +0,0 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(&GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
SetDynamicServers(nil)
runtime := &recoveryRuntime{
transport: transport.NewClient(nil),
flags: &GlobalFlags{Token: "token"},
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+4 -88
View File
@@ -1,94 +1,10 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
// captureRuntimeFailure previously persisted a recovery snapshot for
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
// failure paths stay stable while the visible Deprecated shim remains.
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
+3 -12
View File
@@ -39,7 +39,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
@@ -51,15 +50,11 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
rootResetRecoveryState()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
@@ -477,10 +468,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
@@ -724,8 +715,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
"schema": true, "mcp": true, "upgrade": true,
}
// commandNameSet returns a new set containing every name in base plus extras.
-8
View File
@@ -12,7 +12,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
+40 -13
View File
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
@@ -386,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
root := NewRootCommand()
cmd, _, err := root.Find([]string{"cache", "refresh"})
if err != nil || cmd == nil || cmd == root {
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
if cmd.Hidden || cmd.Deprecated == "" {
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
}
}
+41 -14
View File
@@ -413,6 +413,33 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -678,7 +705,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := apperrors.NewAPI(
mcpErr := newServerFailureAPIError(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
+25 -17
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 266
publicShortcutCount = 294
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 216
schemaPublishedShortcutCount = 295
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 215
publiclyDeliveredShortcutCount = 294
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 129; got != want {
if got, want := int(product["count"].(float64)), 180; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
}
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
+105
View File
@@ -0,0 +1,105 @@
// 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 (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -0,0 +1,223 @@
// 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"
"net/http"
"net/http/httptest"
"strings"
"testing"
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"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
+7 -20
View File
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
生产前请评估,问题请提 issue 反馈
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 多 skill—— 按产品拆 N 个独立 skill
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
// dws-shared carries the global rules every product skill declares as a
// dingtalk-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(out, "已取消。")
return nil
}
} else if mode == skillSetupModeMulti {
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
}
var installed, skipped int
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
@@ -365,10 +361,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
).
Value(&choice),
),
@@ -526,15 +522,6 @@ func detectExistingAgentHomes(home, mode string) []string {
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
}
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
+4 -4
View File
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// contains both the shared routing skill and the PAT product skill. Structured
// contains both the shared routing skill and misc (including folded PAT docs). Structured
// Schema hints are build inputs and must not become a third installable mode.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -71,9 +71,9 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
}
for _, rel := range []string{
filepath.Join("dws-shared", "SKILL.md"),
filepath.Join("dingtalk-pat", "SKILL.md"),
filepath.Join("dingtalk-pat", "references", "pat.md"),
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("empty multi source should fail")
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
+3 -12
View File
@@ -23,11 +23,11 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -117,23 +117,14 @@ type Profile struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
profilectx.Set(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
return profilectx.Get()
}
// ProfilesPath returns the profile metadata path for a config dir.
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
+1 -34
View File
@@ -24,39 +24,6 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
cmd := &cobra.Command{Use: "reply"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("text", "", "text")
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
}
metadata := runtimeSchemaMetadataSources{
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
"text": {Type: "string"},
}},
}},
}
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
oldParameters := resolveRuntimeParameters
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, errors.New("parameters failed")
}
entry := runtimeSchemaEntry{
+12
View File
@@ -158,6 +158,12 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-messages",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +chat-mute",
Aliases: map[string]string{
@@ -336,6 +342,12 @@ var generatedParamAliases = []ParamAliasEntry{
"at-user-ids": "at-users",
},
},
{
CLIPath: "chat +search-msg",
Aliases: map[string]string{
"chat": "group",
},
},
{
CLIPath: "chat +send-to-group",
Aliases: map[string]string{
+5 -1
View File
@@ -57,6 +57,8 @@
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
@@ -255,7 +257,9 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
]
}
}
+91 -184
View File
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
}
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
// schema_mcp_metadata.json is deleted; production assembly does not embed or
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
// through schemaRegistryForTestWithMetadata.
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
// or ranks MCP pin candidates. Optional Interface-registry validators may
// still pass this empty shape when they only need ContractFinal self-checks.
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
}
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
return entries, nil
}
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
// Contract/ParamDecl own interface facts. When a non-empty fixture is
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
if len(mcpMetadata.Tools) == 0 {
return embeddedMCPToolMetadata{}, false
}
if entry.Command != nil {
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
return metadata, true
}
}
}
paths := []string{
entry.PrimaryCLIPath,
entry.CLIPath,
entry.ProductID + "." + entry.ToolName,
}
paths = append(paths, entry.Aliases...)
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
return metadata, true
}
}
for _, key := range []string{
entry.SourceProductID + "." + entry.ToolName,
entry.ProductID + "." + entry.ToolName,
} {
key = strings.Trim(key, ".")
if key == "" {
continue
}
if meta, ok := mcpMetadata.Tools[key]; ok {
return meta, true
}
}
return embeddedMCPToolMetadata{}, false
}
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
productID = strings.TrimSpace(productID)
rpcName = strings.TrimSpace(rpcName)
key := strings.Trim(productID+"."+rpcName, ".")
if key == "" {
return embeddedMCPToolMetadata{}, false
}
metadata, exists := mcpMetadata.Tools[key]
if !exists {
return embeddedMCPToolMetadata{}, false
}
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
return metadata, true
}
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
if cmd == nil || cmd.Annotations == nil {
return "", "", ""
@@ -322,7 +266,6 @@ const (
runtimeSchemaRankDefault = 0
runtimeSchemaRankDerived = 50
runtimeSchemaRankInference = 100
runtimeSchemaRankMCP = 400
runtimeSchemaRankCobraHelp = 450
runtimeSchemaRankCobraDefault = 600
runtimeSchemaRankCobraContract = 610
@@ -332,7 +275,7 @@ const (
runtimeSchemaRankVersionedBinding = 650
// ParamDecl.Property (dws.schema.property) outranks residual versioned
// binding candidates (active bindings JSON is empty after Phase 2).
// Mapping exclusions stay highest so an explicit "no MCP property" review
// Mapping exclusions stay highest so an explicit "no RPC property" review
// cannot be overridden by a leaf ParamDecl that still carries a Property.
runtimeSchemaRankParamDeclProperty = 655
runtimeSchemaRankMappingExclusion = 660
@@ -340,7 +283,6 @@ const (
runtimeSchemaPrecedenceDefault = "default"
runtimeSchemaPrecedenceDerived = "derived_resolution"
runtimeSchemaPrecedenceInference = "inference"
runtimeSchemaPrecedenceMCP = "mcp_metadata"
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
runtimeSchemaPrecedenceCobra = "cobra_contract"
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
}
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
case "mcp_metadata", "pinned_mcp_metadata":
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
case "cobra_help":
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
// versioned property-mapping inputs. An exclusion is an explicit statement
// that the CLI parameter is not a direct MCP property: it therefore supplies
// a present empty candidate (rather than allowing name inference to survive)
// and keeps the review reason in provenance.
// that the CLI parameter is not a direct RPC/interface property: it therefore
// supplies a present empty candidate (rather than allowing name inference to
// survive) and keeps the review reason in provenance.
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
type runtimeParameterFieldContext struct {
flag *pflag.Flag
metadata RuntimeSchemaParameterMetadata
pinnedParam embeddedMCPParamMeta
hasPinned bool
paramType string
constraints RuntimeSchemaConstraints
property string
}
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
)
}
}
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
break
}
}
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
constraintRequired,
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
runtimeSchemaCandidate(false, true, "default"),
}
if c.hasPinned && c.pinnedParam.Required != nil {
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
}
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
runtimeSchemaCandidate("", true, "default"),
)
}
}
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
runtimeSchemaCandidate([]string{}, true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
}
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
// source may intentionally raise or lower type/mapping/description semantics.
// required is different: Cobra MarkFlagRequired is a hard floor that no
// lower-priority source may demote (see resolveRequiredProjection).
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
// MCP pin / mcp_metadata is not a candidate source.
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
if cmd == nil {
return nil, nil
}
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
return
}
property, _ := propertyWinner.Value.(string)
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
// production assembly always passes an empty map (pin retired).
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
}
paramType := runtimeFlagCLIType(flag)
fieldCtx := runtimeParameterFieldContext{
flag: flag,
metadata: metadata,
pinnedParam: pinnedParam,
hasPinned: hasPinnedParam,
paramType: paramType,
constraints: constraints,
property: property,
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
return
}
description, _ := descriptionWinner.Value.(string)
interfaceDescription := ""
if hasPinnedParam {
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
}
// Required uses field-level safe merge: higher sources may raise required, but
// Cobra MarkFlagRequired cannot be projected away as optional.
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
)
}
if interfaceDescription != "" && interfaceDescription != description {
parameter.InterfaceDescription = interfaceDescription
}
if interfaceType != "" && interfaceType != paramType {
parameter.InterfaceType = interfaceType
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
if def := runtimeFlagDefault(flag); def != "" {
parameter.Default = runtimeSchemaJSONString(def)
}
if hasPinnedParam {
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
}
}
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
if !ok {
return
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
return runtimeannotate.ConstraintsEmpty(constraints)
}
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
if len(params) == 0 {
return embeddedMCPParamMeta{}, false
}
if meta, ok := params[property]; ok {
return meta, true
}
if meta, ok := params[flagName]; ok {
return meta, true
}
return embeddedMCPParamMeta{}, false
}
func isGenericPayloadFlag(flag *pflag.Flag) bool {
if flag == nil {
return false
@@ -1142,6 +1025,12 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
}
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
// JSON Schema's date-time format means one RFC3339 value. Do not publish
// that narrower wire contract when the CLI also accepts local timestamps
// or date-only values alongside RFC3339.
if strings.Contains(usage, "yyyy-mm-dd") {
return ""
}
return "date-time"
}
if strings.Contains(usage, "a1") {
@@ -1156,69 +1045,87 @@ func strconvQuote(value string) string {
// ─── --compact mode ──────────────────────────────────────────────────────────
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
var schemaCompactStripKeys = map[string]bool{
// provenance / debug
"agent_metadata_source": true,
"agent_source_refs": true,
"agent_summary_source": true,
"effect_source": true,
"metadata_source": true,
"source": true,
"agent_metadata": true,
"field_provenance": true,
"reviewed": true,
// redundant with canonical_path / cli_path
"name": true,
"path": true,
"cli_name": true,
"primary_cli_path": true,
"is_alias": true,
"has_parameters": true,
"parameter_count": true,
"product_id": true,
"display": true,
"title": true,
"group": true,
"source_product_id": true,
"aliases": true,
"catalog_hash": true,
"surface_hash": true,
"workflow_refs": true,
"prerequisites": true,
"tips": true,
"interface_ref": true,
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
// positive list: a new full/audit field must not silently expand routine Agent
// context just because it was added to ToolSpec.ToPayload.
var schemaCompactPayloadKeys = map[string]bool{
// Navigation envelopes.
"kind": true, "level": true, "count": true, "tool_count": true,
"products": true, "product": true, "tools": true,
"id": true, "schema_path": true, "runtime": true,
// Leaf identity and execution semantics.
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"examples": true, "use_when": true, "avoid_when": true,
}
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
var schemaCompactParamStripKeys = map[string]bool{
"interface_description": true,
"interface_type": true,
"property": true,
"field_provenance": true,
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
// construction. RPC mapping and provenance fields intentionally remain in the
// full/audit view.
var schemaCompactParamKeys = map[string]bool{
"type": true, "description": true, "required": true,
"cli_required": true, "required_when": true,
"default": true, "interface_default": true, "example": true,
"format": true, "enum": true,
}
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
// debug and redundant keys so that only agent-essential fields remain.
// It operates recursively on nested maps, slices, and parameter objects.
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
result := make(map[string]any, len(payload))
for k, v := range payload {
if schemaCompactStripKeys[k] {
if !schemaCompactPayloadKeys[k] {
continue
}
if k == "parameters" {
switch k {
case "parameters":
result[k] = stripSchemaParametersCompact(v)
continue
case "product":
if product, ok := v.(map[string]any); ok {
result[k] = stripSchemaPayloadCompact(product)
} else {
result[k] = v
}
case "products", "tools":
result[k] = stripSchemaPayloadCollectionCompact(v)
default:
result[k] = v
}
result[k] = stripSchemaValueCompact(v)
}
return result
}
func stripSchemaPayloadCollectionCompact(value any) any {
switch values := value.(type) {
case []map[string]any:
result := make([]map[string]any, len(values))
for i, item := range values {
result[i] = stripSchemaPayloadCompact(item)
}
return result
case []any:
result := make([]any, len(values))
for i, item := range values {
if payload, ok := item.(map[string]any); ok {
result[i] = stripSchemaPayloadCompact(payload)
} else {
result[i] = item
}
}
return result
default:
return value
}
}
func stripSchemaParametersCompact(value any) any {
parameters, ok := value.(map[string]any)
if !ok {
@@ -1272,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
func stripSchemaParamCompact(param map[string]any) map[string]any {
result := make(map[string]any, len(param))
for k, v := range param {
if schemaCompactParamStripKeys[k] {
if !schemaCompactParamKeys[k] {
continue
}
result[k] = v
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
}
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
t.Fatal("empty lookup unexpectedly matched")
}
leaf := &cobra.Command{Use: "reply"}
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
CLIPath: "sample run", PrimaryCLIPath: "sample run",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {
Parameters: map[string]embeddedMCPParamMeta{
"clawType": {Type: "string"},
},
},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
}
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
}
for _, test := range []struct {
value any
want int
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
cmd.Flags().String("value", "", "value")
flag := cmd.Flags().Lookup("value")
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{}, errors.New("load failed")
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
t.Fatalf("binding load error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
MappingExclusions: map[string]string{"sample.run --value": " "},
}, nil
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
t.Fatalf("mapping exclusion error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
}
return resolveRuntimeSchemaCandidate(field, candidates...)
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
t.Fatalf("%s resolution error = %v", target, err)
}
}
resolveRuntimeSchemaField = realResolver
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("empty specs = %#v, err = %v", specs, err)
}
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
t.Fatalf("empty payload = %#v, err = %v", payload, err)
}
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
})
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
t.Fatalf("payload serialization error = %v", err)
}
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
t.Fatalf("required annotation = %v/%v", required, present)
}
// Fixture MCP-shaped maps still participate when explicitly injected.
// Binding snapshot still supplies reviewed property mappings without MCP pin.
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
}, nil
})
requiredTrue := true
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
"clawType": {
Type: "string",
Description: "fixture description",
Required: &requiredTrue,
Default: "fixture-default",
},
}, RuntimeSchemaConstraints{})
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
if err != nil {
t.Fatalf("fixture pinned parameter specs error = %v", err)
t.Fatalf("parameter specs error = %v", err)
}
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
t.Fatalf("fixture pinned parameter specs = %#v", specs)
if len(specs) != 1 || specs[0].Property != "clawType" {
t.Fatalf("parameter specs = %#v", specs)
}
if len(specs[0].InterfaceDefault) == 0 {
t.Fatalf("fixture interface_default missing: %#v", specs[0])
}
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
}
}
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
t.Fatalf("normalized groups = %#v", groups)
}
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
}
if isGenericPayloadFlag(nil) {
t.Fatal("nil flag cannot be a generic payload")
}
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
if _, exists := value["property"]; exists {
t.Fatalf("compact parameter value = %#v", value)
}
// Non-parameter nested maps fall through to payload compacting.
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
if nested["description"] != "keep" {
t.Fatalf("nested non-param map = %#v", nested)
}
if _, exists := nested["provenance"]; exists {
t.Fatalf("nested non-param provenance should drop: %#v", nested)
}
// Type-only maps still count as parameter objects.
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
if _, exists := typedOnly["property"]; exists {
t.Fatalf("type-only param value = %#v", typedOnly)
}
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
t.Fatalf("value compact []map = %#v", mapSlice)
}
if _, exists := mapSlice[0]["provenance"]; exists {
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
}
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
if len(anySlice) != 2 || anySlice[1] != "raw" {
t.Fatalf("value compact []any = %#v", anySlice)
}
payload := map[string]any{
"description": "calendar",
"provenance": map[string]any{"source": "drop"},
"parameters": parameters,
"product": map[string]any{"description": "calendar", "provenance": "drop"},
"products": []map[string]any{
{"description": "calendar", "provenance": "drop"},
},
"tools": []any{
map[string]any{"description": "leaf", "provenance": "drop"},
"skip-me",
},
"constraints": map[string]any{"require_one_of": []any{}},
}
stripped := stripSchemaPayloadCompact(payload)
if stripped["description"] != "calendar" {
t.Fatalf("compact description = %#v", stripped["description"])
}
if _, exists := stripped["provenance"]; exists {
t.Fatalf("compact should drop provenance: %#v", stripped)
}
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
t.Fatalf("compact product = %#v", stripped["product"])
}
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
}
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
t.Fatalf("compact products = %#v", stripped["products"])
}
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
t.Fatalf("compact tools = %#v", stripped["tools"])
}
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
}
if stripped["tools"].([]any)[1] != "skip-me" {
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
}
// Non-map product values are retained verbatim.
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
t.Fatalf("non-map product = %#v", got["product"])
}
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
t.Fatalf("non-collection compact = %#v", got)
}
}
+9 -18
View File
@@ -30,38 +30,29 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode string
type AgentExampleMode = contract.ExampleDispositionMode
const (
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode string
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
const (
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
-19
View File
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
return out
}
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
seen := map[string]bool{}
for _, path := range paths {
for _, candidate := range []string{
strings.TrimSpace(path),
strings.Join(splitSchemaPathTokens(path), " "),
} {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
if metadata, ok := source.Tools[candidate]; ok {
return metadata, true
}
}
}
return agentToolMetadata{}, false
}
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
// runtime delivery and CI dumps hash-aligned without requiring build-time
+9 -122
View File
@@ -20,7 +20,6 @@ import (
"testing/fstest"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
// longer participates in assembly.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
}
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("leaf examples = %#v", leaf["examples"])
}
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
catalog, err := runtimeSchemaPayloadForTest(root, nil)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
}
@@ -136,7 +134,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("product summary must not include examples: %#v", tools[0])
}
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
registry, err := schemaRegistryForTest(root)
if err != nil {
t.Fatalf("schemaRegistryForTest(): %v", err)
}
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
// exercises the production assembly path.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
registry, err := schemaRegistryForTest(root)
if err != nil {
t.Fatal(err)
}
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
}
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
// interface_ref declares on the leaf ContractFinal; the injected MCP
// fixture participates through the gated fixture lookup (remapped via the
// declared Interface.Ref).
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
// parameter candidate source.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
payload.Interface = &contract.InterfaceSpec{
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
}
})
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"documents.create_doc_v2": {
Parameters: map[string]embeddedMCPParamMeta{
"title": {Description: "MCP document title"},
},
},
},
}
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
if err != nil {
t.Fatal(err)
}
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
}
parameters, _ := payload["parameters"].(map[string]any)
title, _ := parameters["title"].(map[string]any)
if title["interface_description"] != "MCP document title" {
t.Fatalf("title metadata = %#v", title)
if payload["interface_mode"] != contract.InterfaceModeMCP {
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
}
}
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
required := true
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Required: &required},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 0, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["required"] != true {
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
}
}
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Default: "50"},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 10, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["default"] != "10" || limit["interface_default"] != "50" {
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
}
}
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
// assemble through the production path.
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
t.Helper()
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
CLIPath: "sample list", PrimaryCLIPath: "sample list",
},
Title: "List items",
Description: "List sample items",
Safety: &contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Interface: &contract.InterfaceSpec{
Mode: "local", Availability: "available", Reason: "test local leaf",
},
Selection: &contract.SelectionSpec{
AgentSummary: "List sample items",
UseWhen: []string{"list sample items"},
AvoidWhen: []string{"not listing"},
},
})
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sample",
Selection: contract.ProductSelectionDecl{
AgentSummary: "Sample product",
UseWhen: []string{"sample routing"},
AvoidWhen: []string{"not sample"},
},
})
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
}
func findSchemaProduct(products []map[string]any, id string) map[string]any {
for _, product := range products {
if product["id"] == id {
+1
View File
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
+7 -3
View File
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
leaf["future_audit_field"] = "must not leak into Agent view"
for _, raw := range schemaMap(leaf["parameters"]) {
raw["future_mapping_field"] = "must not leak into Agent view"
}
stripped := stripSchemaPayloadCompact(leaf)
// Must keep agent-essential fields.
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
}
// Must strip provenance / redundant fields.
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
if _, ok := stripped[key]; ok {
t.Fatalf("compact leaf still contains stripped key %q", key)
}
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if params, ok := stripped["parameters"].(map[string]any); ok {
for name, p := range params {
if pm, ok := p.(map[string]any); ok {
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
if _, present := pm[stripped]; present {
t.Fatalf("compact param %q still contains %q", name, stripped)
}
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
}
if want.interfaceType != "" {
prov := schemaMap(param["field_provenance"])["interface_type"]
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
if src, _ := prov["source"].(string); src != "native_annotation" {
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
}
}
+1 -62
View File
@@ -20,7 +20,7 @@ package cli
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
{
ID: "cli-management",
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reviewed: true,
Commands: []string{
"api",
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"profile list",
"profile switch",
"profile use",
"recovery execute",
"recovery finalize",
"recovery plan",
"schema",
"skill get",
"skill install",
@@ -171,62 +168,4 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"todo task remove-attachment",
},
},
{
ID: "chat-shortcuts-pending-schema-curation",
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
Reviewed: true,
Commands: []string{
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-list",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send-by-bot",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
},
},
}
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
return selection
}
})
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
}}
return selection
}
})
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
+2 -2
View File
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
// runtimeCommandParameters is the compatibility wire adapter used only by
// tests; resolution happens in runtimeCommandParameterSpecs.
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
if err != nil {
return nil, err
}
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
prevSpecs := runtimeCommandParameterSpecsForPayload
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, fmt.Errorf("specs boom")
}
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
t.Fatal("parameter specs error must surface")
}
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
}
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
if err != nil || payload["ok"] == nil {
t.Fatalf("parameter payload = %#v err=%v", payload, err)
}
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
}
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
t.Fatal("nil pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
t.Fatal("empty pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
t.Fatal("unmatched pinned params must miss")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
t.Fatal("empty MCP metadata must not match")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
}); ok {
t.Fatal("missing MCP metadata keys must not match")
}
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Bool("all", false, "fetch every page")
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
if err != nil {
t.Fatal(err)
}
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
return AssembleSchemaRegistryFromBound(bound)
}
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
bound, err := boundTestCommandRegistry(root)
if err != nil {
return SchemaRegistry{}, err
}
// Production-shaped assembly: leaves must carry ContractFinal and products
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
// fixtures participate only through the gated fixture lookup in
// runtimeToolSpecFromContractFinal; production passes an empty pin.
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
}
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
// declarations for the synthetic doc.create_document tree built by
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
@@ -156,18 +144,6 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
if err != nil {
return nil, err
}
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
if err != nil {
return nil, err
}
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
registry, err := schemaRegistryForTest(root)
if err != nil {
+15 -16
View File
@@ -15,8 +15,10 @@ import (
)
type runtimeSchemaMetadataSources struct {
// Agent remains only for historical test seams that still construct this
// struct; production assembly does not overlay Agent or MCP pin onto
// parameters or tool text.
Agent agentMetadata
MCP embeddedMCPMetadata
}
var (
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
}
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
return runtimeSchemaMetadataSources{
Agent: runtimeAgentMetadata(),
MCP: emptyPinnedMCPMetadata(),
}
// Production pin and Agent inject are both retired; assembly is Contract /
// ParamDecl / Cobra only.
return runtimeSchemaMetadataSources{}
}
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
@@ -123,7 +124,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
// assembleSchemaRegistryFromBound resolves every entry through the
// ContractFinal / ProductDecl production path. Missing declarations fail
// closed; retired skill/MCP/agent-inject overlays are never reopened.
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
entries, err := assembleCollectEntries(bound)
if err != nil {
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
// Declared values are the final data source; hints/registry text does not merge.
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
// interface_type / interface_* facts come from ParamDecl / native annotations.
// Tests may still inject a non-empty MCP fixture map, which participates through
// pinnedMCPMetadataForEntryFrom.
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
// native annotations only.
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
_ = metadata // reserved for historical assemble seams; no overlay sources remain
canonicalPath := entry.ProductID + "." + entry.ToolName
constraints := runtimeCommandConstraints(entry.Command)
var pinnedParams map[string]embeddedMCPParamMeta
if len(metadata.MCP.Tools) > 0 {
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
pinnedParams = pinnedMeta.Parameters
}
// Apply parameter declarations from the contract.ContractFinalPayload before the
// resolver reads them. The decls were put there by AttachContract at
// DeclareLeafMetadata time; now that all flags exist on the fully-built
@@ -216,7 +211,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
}
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
if err != nil {
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
}
@@ -332,6 +327,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
}
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
t.Fatal("blank interface ref must miss")
}
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
t.Fatal("missing MCP tool must miss")
}
agent := agentMetadata{Tools: map[string]agentToolMetadata{
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
}}
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
}, agent, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
// MCP pin lookup helpers are retired; keep this named coverage slot as a
// no-op marker so CrossPlatformCoverage* selection stays stable.
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
t.Fatalf("empty pinned metadata = %#v", got)
}
}
+48
View File
@@ -200,6 +200,10 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+15 -10
View File
@@ -103,11 +103,12 @@ const ValidationShortcut FlagValidationMode = "shortcut"
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
// helpers can alias to it without touching any call site.
type FlagSpec struct {
Name string // flag name (kebab-case)
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
Name string // flag name (kebab-case)
Shorthand string // optional one-character Cobra shorthand
Usage string // registration usage text
Kind FlagKind // value type, defaults to KindString
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
Hidden bool // hide the real flag from help/Schema while keeping it invocable
// Required, when true, validates a non-empty effective value in RunE. Plain
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
@@ -551,7 +552,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
flag.Name))
}
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
// Aliases are registered with the main flag's Kind, otherwise an integer
// alias's value would never be readable (silently dropped).
for _, alias := range flag.Aliases {
@@ -572,6 +573,10 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
// instead of silently degrading to 0 / false.
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
registerFlagP(cmd, kind, name, "", def, usage)
}
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
switch kind {
case KindInt:
defInt := 0
@@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
}
defInt = v
}
cmd.Flags().Int(name, defInt, usage)
cmd.Flags().IntP(name, shorthand, defInt, usage)
case KindBool:
defBool := false
if def != "" {
@@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
}
}
cmd.Flags().Bool(name, defBool, usage)
cmd.Flags().BoolP(name, shorthand, defBool, usage)
case KindStringSlice:
var defaults []string
if value := strings.TrimSpace(def); value != "" {
defaults = strings.Split(value, ",")
}
cmd.Flags().StringSlice(name, defaults, usage)
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
default:
cmd.Flags().String(name, def, usage)
cmd.Flags().StringP(name, shorthand, def, usage)
}
}
+9 -4
View File
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
cmd := newTestCommand()
RegisterFlags(cmd, []FlagSpec{
{Name: "s", Usage: "S", Default: "d"},
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
{Name: "req", Usage: "R", MarkRequired: true},
{Name: "hidden", Usage: "H", Hidden: true},
})
@@ -78,6 +78,11 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
t.Fatalf("string flag = %#v", f)
}
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
}
}
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
f := cmd.Flags().Lookup(name)
if f == nil || f.Value.Type() != wantType {
+66
View File
@@ -46,6 +46,9 @@ type Error struct {
Message string
Operation string
ServerKey string
Origin string
FailureStage string
ExecutionStarted *bool
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
@@ -55,6 +58,7 @@ type Error struct {
Actions []string
AvailableFlags []string
Snapshot string
Details map[string]any
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
@@ -107,6 +111,32 @@ func WithServerKey(serverKey string) Option {
}
}
// WithOrigin records the component that produced the failure, such as the
// client, MCP gateway, or DingTalk API. It is independent from Category,
// which remains the stable exit-code contract.
func WithOrigin(origin string) Option {
return func(err *Error) {
err.Origin = strings.TrimSpace(origin)
}
}
// WithFailureStage records the execution stage at which the failure occurred.
func WithFailureStage(stage string) Option {
return func(err *Error) {
err.FailureStage = strings.TrimSpace(stage)
}
}
// WithExecutionStarted records whether the downstream business operation was
// known to have started. Unknown state must be represented by omitting this
// option, which is important for safe retry decisions on write operations.
func WithExecutionStarted(started bool) Option {
return func(err *Error) {
value := started
err.ExecutionStarted = &value
}
}
// WithRetryable marks whether the error can be retried safely.
func WithRetryable(retryable bool) Option {
return func(err *Error) {
@@ -186,6 +216,21 @@ func WithSnapshot(path string) Option {
}
}
// WithDetails records an additive machine-readable payload for errors whose
// recovery needs typed context, such as ambiguous target-resolution
// candidates. Callers must keep credentials and other secrets out of details.
func WithDetails(details map[string]any) Option {
return func(err *Error) {
if len(details) == 0 {
return
}
err.Details = make(map[string]any, len(details))
for key, value := range details {
err.Details[key] = value
}
}
}
// WithRPCCode records the original JSON-RPC error code.
func WithRPCCode(code int) Option {
return func(err *Error) {
@@ -295,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Origin != "" {
errorPayload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
errorPayload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
errorPayload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
@@ -316,6 +370,9 @@ func PrintJSON(w io.Writer, err error) error {
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
if len(typed.Details) > 0 {
errorPayload["details"] = typed.Details
}
if typed.RPCCode != 0 {
errorPayload["rpc_code"] = typed.RPCCode
}
@@ -447,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if typed.ServerKey != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
}
if typed.Origin != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
}
if typed.FailureStage != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
}
if typed.ExecutionStarted != nil {
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
}
if typed.Snapshot != "" {
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
}
+38 -10
View File
@@ -20,7 +20,7 @@ import (
"time"
)
func TestExitCodeByCategory(t *testing.T) {
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) {
}
}
func TestPrintJSON(t *testing.T) {
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("dws schema doc.create_document", "retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
WithDetails(map[string]any{
"type": "resolution",
"query": "项目群",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
@@ -67,6 +74,11 @@ func TestPrintJSON(t *testing.T) {
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
t.Fatalf("expected reason in output, got %q", got)
}
if !strings.Contains(got, "\"origin\": \"client\"") ||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
!strings.Contains(got, "\"execution_started\": false") {
t.Fatalf("expected failure provenance in output, got %q", got)
}
if !strings.Contains(got, "\"retryable\": true") {
t.Fatalf("expected retryable in output, got %q", got)
}
@@ -76,6 +88,9 @@ func TestPrintJSON(t *testing.T) {
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
t.Fatalf("expected snapshot path in output, got %q", got)
}
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
t.Fatalf("expected structured details in output, got %q", got)
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
@@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestPrintHuman(t *testing.T) {
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -201,6 +216,9 @@ func TestPrintHuman(t *testing.T) {
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
WithServerKey("calendar"),
WithOrigin("client"),
WithFailureStage("request_validation"),
WithExecutionStarted(false),
WithHint("Pass the required flag and retry."),
WithRetryable(true),
WithActions("retry command"),
@@ -228,9 +246,19 @@ func TestPrintHuman(t *testing.T) {
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in verbose output, got %q", want, got)
}
}
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
if withoutDetails.Details != nil {
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
}
}
func TestPrintHuman_NormalMode(t *testing.T) {
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) {
}
}
func TestPrintJSONIncludesServerDiag(t *testing.T) {
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) {
}
}
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) {
}
}
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
@@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
}
}
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
+6
View File
@@ -3,6 +3,7 @@ package helpers
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -78,6 +79,8 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
_ = cmd.Flags().MarkHidden("type")
}
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
@@ -90,6 +93,9 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
@@ -28,3 +28,13 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
}
}
}
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
t.Fatal(err)
}
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
t.Fatal("invalid person type selector unexpectedly succeeded")
}
}
+179 -2
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+213 -74
View File
@@ -18,6 +18,7 @@ import (
"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/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -53,6 +54,43 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
command := hintSubCmd(use, hint)
// Legacy callers may still pass the old command's flags. Let the migration
// command consume them so Cobra reaches RunE and returns the replacement path.
command.DisableFlagParsing = true
return command
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func resolveNativeChatTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if targetresolver.LooksLikeOpenConversationID(raw) {
return raw, nil
}
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
if err != nil {
return "", err
}
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
@@ -78,6 +116,61 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
return v
}
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
if len(args) == 1 {
if keyword != "" {
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
}
keyword = strings.TrimSpace(args[0])
}
if keyword == "" {
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
}
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
cmd := &cobra.Command{
Use: "search [query]",
Short: "根据关键词搜索群聊",
Hidden: hidden,
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
Args: cobra.MaximumNArgs(1),
RunE: runChatGroupSearch,
}
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().String("name", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("name")
cmd.Flags().String("group", "", "--query 的兼容别名")
_ = cmd.Flags().MarkHidden("group")
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = cmd.Flags().MarkHidden("size")
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
return cmd
}
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
return err
@@ -311,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
return text
}
// applyCurrentUserGroupMentions keeps the body placeholders and
// send_personal_message mention arguments aligned for send and reply.
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
var atOpenIDs []string
if rawOpenIDs != "" {
atOpenIDs = strings.Split(rawOpenIDs, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
text = normalizeAtPlaceholders(text, atOpenIDs, true)
if atAll {
params["atAll"] = true
}
if len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
}
return text
}
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
if rawOpenIDs == "" {
return text
}
missing := make([]string, 0)
probeText := text
for _, id := range parseCSVValues(rawOpenIDs) {
placeholder := "<@" + id + ">"
if strings.Contains(probeText, placeholder) {
continue
}
missing = append(missing, placeholder)
probeText += placeholder
}
if len(missing) == 0 {
return text
}
prefix := strings.Join(missing, " ")
if strings.HasPrefix(text, "<@all> ") {
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
}
return prefix + " " + text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
@@ -1277,34 +1414,23 @@ func newChatCommand() *cobra.Command {
},
})
chatSearchCmd := &cobra.Command{
Use: "search",
Short: "根据关键词搜索群聊",
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
注意:
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
Example: ` dws chat search --query "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
}
limit := chatIntFlagOrFallback(cmd, "limit", "size")
cursor, _ := cmd.Flags().GetString("cursor")
toolArgs := map[string]any{
"keyword": keyword,
"limit": limit,
"cursor": cursor,
}
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
toolArgs["excludeMuted"] = true
}
return callMCPToolOnServer("im", "search_groups", toolArgs)
chatSearchCmd := newChatGroupSearchCommand(false)
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
cli.AttachRuntimeSchema(
chatGroupSearchCompatibilityCmd,
"chat",
"search_groups",
"reviewed-compatibility:chat-group-search",
)
cli.AnnotateRuntimeCompatibilityEquivalence(
chatSearchCmd,
chatGroupSearchCompatibilityCmd,
cli.RuntimeCompatibilityEquivalence{
ID: "chat-group-search-compatibility-v1",
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
Reviewed: true,
},
}
)
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -1955,29 +2081,15 @@ func newChatCommand() *cobra.Command {
if groupID != "" {
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
var atOpenIds []string
if atOpenIdsStr != "" {
atOpenIds = strings.Split(atOpenIdsStr, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams := map[string]any{
"openConversationId": groupID,
"msgType": "markdown",
"content": string(contentJSON),
"clawType": clawType,
}
if atAll {
newParams["atAll"] = true
}
if len(atOpenIds) > 0 {
newParams["atOpenDingTalkIds"] = atOpenIds
}
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams["content"] = string(contentJSON)
if msgUuid != "" {
newParams["uuid"] = msgUuid
}
@@ -3353,15 +3465,6 @@ func newChatCommand() *cobra.Command {
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
_ = chatSearchCmd.Flags().MarkHidden("keyword")
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
_ = chatSearchCmd.Flags().MarkHidden("size")
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
_ = chatGroupMembersCmd.MarkFlagRequired("id")
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
@@ -3387,7 +3490,7 @@ func newChatCommand() *cobra.Command {
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
// message 子命令 flags
@@ -3940,6 +4043,7 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3947,6 +4051,14 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -5294,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageReplyCmd := &cobra.Command{
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
@@ -5313,13 +5426,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
refSender = resolved
}
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": mustGetFlag(cmd, "text"),
}
contentJSON, _ := marshalJSONRaw(replyContent)
clawType := ""
aiTag, _ := cmd.Flags().GetBool("ai-tag")
if aiTag {
@@ -5328,9 +5434,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"msgType": "reply",
"content": string(contentJSON),
"clawType": clawType,
}
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "text"),
atOpenIDs,
atAll,
)
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": replyText,
}
contentJSON, _ := marshalJSONRaw(replyContent)
toolArgs["content"] = string(contentJSON)
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
@@ -5364,6 +5486,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "conversation-id", Property: "openConversationId"},
},
},
@@ -5378,6 +5502,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageReplyCmd.MarkFlagRequired("text")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
// ── message forward: 转发单条消息 ────────────────────────
@@ -6261,8 +6387,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
if err != nil {
return err
}
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openConversationId": groupID,
})
},
}
@@ -6296,7 +6426,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
},
})
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
_ = chatGroupBotsCmd.MarkFlagRequired("group")
chatGroupMembersRemoveBotCmd := &cobra.Command{
@@ -6432,7 +6562,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
Use: "dismiss",
Short: "解散群聊",
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
Example: ` dws chat group dismiss --group <openConversationId> --yes
Example: ` dws chat group dismiss --group <openConversationId>
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -7276,7 +7406,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -7288,6 +7418,14 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -7763,8 +7901,8 @@ status 可选值:
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
@@ -8080,10 +8218,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// hint: dws chat send → dws chat message send
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
// hint: dws chat history → dws chat message list
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
// compatibility hints available through both chat and im.
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
@@ -43,6 +43,75 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
return command.RunE(command, nil)
}
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
t.Fatalf("stable native chat target = %q, %v", got, err)
}
t.Run("group search path accepts query", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("group search accepts positional", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d", caller.calls)
}
})
t.Run("native bots resolves group name", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
{text: `{"result":{"bots":[]}}`},
}}
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d", caller.calls)
}
})
}
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
root := newChatCommand()
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
}
for _, tc := range []struct {
path string
args []string
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if len(remaining) != 0 || command.Name() != tc.path {
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
}
if !command.Hidden || !command.Runnable() {
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
}
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -112,6 +181,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
{"category", "delete", "--category-id=1"},
{"category", "rename", "--category-id=1", "--title=renamed"},
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
@@ -15,6 +15,7 @@ package helpers
import (
"context"
"encoding/json"
"io"
"os"
"reflect"
@@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
tests := []struct {
name string
args []string
contentField string
wantContent string
wantAtAll bool
wantOpenIDs []string
}{
{
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
},
{
name: "reply",
args: []string{
"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",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "reply adds missing member placeholders",
args: []string{
"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",
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
},
{
name: "reply adds missing member placeholders after at-all",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
},
{
name: "reply at-all preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 联系 @alliance",
wantAtAll: true,
},
{
name: "reply without at flags preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
},
contentField: "content",
wantContent: "联系 @alliance",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
gotAtAll, hasAtAll := args["atAll"]
if tc.wantAtAll {
if !hasAtAll || gotAtAll != true {
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
}
} else if hasAtAll {
t.Fatalf("atAll = %#v; want absent", gotAtAll)
}
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
if len(tc.wantOpenIDs) > 0 {
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
}
} else if hasOpenIDs {
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
}
var content map[string]string
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if got := content[tc.contentField]; got != tc.wantContent {
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
-1
View File
@@ -144,7 +144,6 @@ var (
"clear": {},
"refresh": {},
"recover": {},
"recovery": {},
"login": {},
"logout": {},
"register": {},
+80 -6
View File
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self: 邀请与更新员工
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
@@ -15,6 +15,7 @@ import (
"testing"
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"
)
@@ -80,6 +81,101 @@ func requireTypedConfirmationError(t *testing.T, err error) {
}
}
type guardedStepCaller struct {
calls []guardedMutationCall
steps []string
index int
dryRun bool
}
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if c.index < len(c.steps) {
text = c.steps[c.index]
}
c.index++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*guardedStepCaller) Format() string { return "json" }
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
func (*guardedStepCaller) Fields() string { return "" }
func (*guardedStepCaller) JQ() string { return "" }
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
baseArgs := []string{
"message", "share-to-chat",
"--email", "user@company.com",
"--id", "msg-1",
"--users", "uid1",
}
caller := &guardedMutationCaller{}
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
stepCaller := &guardedStepCaller{steps: []string{
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
`{"ok":true}`,
}}
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed share with sign returned error: %v", err)
}
if len(stepCaller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
}
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
}
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
t.Fatalf("second call sign = %q, want sig", sign)
}
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed direct success returned error: %v", err)
}
if len(directCaller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
}
if directCaller.calls[0].toolName != "share_message_to_chat" {
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
}
dryRunCaller := &guardedMutationCaller{dryRun: true}
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
if err != nil {
t.Fatalf("dry-run without --yes returned error: %v", err)
}
if len(dryRunCaller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
}
}
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file is adapted from Go standard library's internal/diff package.
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
//
// Modifications:
// - Package changed from "diff" to "products"
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
// - const C replaced with the contextLines parameter
package helpers
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type diffPair struct{ x, y int }
func nonNeg(v int) int {
if v < 0 {
return 0
}
return v
}
// UnifiedDiff returns an anchored diff of the two texts old and new
// in the "unified diff" format. If old and new are identical,
// UnifiedDiff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of "unique" lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an "anchored diff" because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a "patience diff," named for
// the "patience sorting" algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
if bytes.Equal(old, new) {
return nil
}
x := diffLines(old)
y := diffLines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done diffPair // printed up to x[:done.x] and y[:done.y]
chunk diffPair // start lines of current chunk
count diffPair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range diffTgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far as possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitely do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := contextLines
if C < 0 {
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
C = 0
}
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = diffPair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// diffLines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func diffLines(x []byte) []string {
l := strings.SplitAfter(string(x), "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// diffTgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, "A Special Case of the Maximal Common
// Subsequence Problem," Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func diffTgs(x, y []string) []diffPair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]diffPair, 2+k)
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = diffPair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = diffPair{0, 0} // sentinel at start
return seq
}
+64 -7
View File
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -3607,11 +3657,13 @@ CLI 内部自动完成全部流程:
PROCESSING 处理中
SUCCESS 导出成功,返回 downloadUrl
FAILED 导出失败`,
Example: ` dws doc export get --job-id <JOB_ID>`,
Example: ` dws doc export get --job-id <JOB_ID>
dws doc export get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
jobID := mustGetFlag(cmd, "job-id")
if jobID == "" {
return fmt.Errorf("flag --job-id is required")
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
if err != nil {
return err
}
if deps.Caller.DryRun() {
@@ -3678,9 +3730,14 @@ CLI 内部自动完成全部流程:
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "job-id", Property: "jobId"},
},
},
})
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
_ = exportGetCmd.Flags().MarkHidden("task-id")
// --node 的隐藏别名(与 doc 下其他命令保持一致)
exportCmd.Flags().String("url", "", "--node 的别名")
@@ -4227,7 +4284,7 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
return root
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "github.com/spf13/cobra"
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
// to the Shortcut application layer. The Cobra leaf still owns its own flags
// and Contract; this bridge only shares the raw/API execution primitive.
func RunDocImportShortcut(cmd *cobra.Command) error {
return runImportCommand(cmd, nil, docImportFlowConfig())
}
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
// insertion implementation with the canonical Doc Shortcut.
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
return runMediaInsert(cmd, nil)
}
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
return runDocStyleCoverSet(cmd, nil)
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
whiteboardDefaultHeight = 600
)
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
// 继续调用 whiteboard query/update。
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
var (
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
whiteboardSleep = time.Sleep
whiteboardJSONMarshal = json.Marshal
prepareWhiteboardCard = prepareJsonMLNode
)
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
node := []any{
"card",
map[string]any{
"uuid": blockUUID,
"cardType": "hetu",
"height": whiteboardDefaultHeight,
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
},
[]any{"span", map[string]any{"data-type": "text"},
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
}
out, err := whiteboardJSONMarshal(node)
if err != nil {
return ""
}
return string(out)
}
func extractWhiteboardID(attrs map[string]any) string {
meta, _ := attrs["metadata"].(map[string]any)
if meta == nil {
return ""
}
id, _ := meta["id"].(string)
return id
}
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID,
"blockId": blockID,
"format": "jsonml",
})
if err != nil {
return nil, err
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
blocksField, ok := data["blocks"]
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
}
blocks, ok := blocksField.([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
}
var raw string
for _, block := range blocks {
entry, _ := block.(map[string]any)
if entry == nil || entry["blockId"] != blockID {
continue
}
raw, _ = entry["jsonml"].(string)
break
}
if raw == "" {
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
}
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
return nil, fmt.Errorf("parse block jsonml: %w", err)
}
return node, nil
}
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
if err != nil {
return nil, err
}
if len(node) < 2 {
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
}
attrs, _ := node[1].(map[string]any)
if attrs == nil {
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
}
return attrs, nil
}
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
blockUUID := uuid.New().String()
whiteboardID := uuid.New().String()
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
normalized, err := prepareWhiteboardCard(cmd, element)
if err != nil {
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
}
toolArgs := map[string]any{
"nodeId": nodeID,
"jsonml": normalized,
"format": "jsonml",
}
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
toolArgs["referenceBlockId"] = v
where, _ := cmd.Flags().GetString("where")
if where == "" {
where = "after"
}
toolArgs["where"] = where
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
toolArgs["referenceBlockId"] = v
}
if cmd.Flags().Changed("index") {
index, _ := cmd.Flags().GetInt("index")
toolArgs["index"] = index
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
ctx := cmd.Context()
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
return err
}
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
persistedID := ""
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
switch {
case queryErr == nil:
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
persistedID = extractWhiteboardID(attrs)
case errors.Is(queryErr, errWhiteboardBlockPending):
// 块暂不可见,属于最终一致性,继续重试。
default:
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
return fmt.Errorf(
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
blockUUID, queryErr)
}
if persistedID != "" {
break
}
if attempt < len(whiteboardRetryDelays) {
whiteboardSleep(whiteboardRetryDelays[attempt])
}
}
result := map[string]any{"blockId": blockUUID}
if persistedID == "" {
result["whiteboardId"] = nil
deps.Out.PrintWarning(fmt.Sprintf(
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
nodeID, blockUUID))
} else {
result["whiteboardId"] = persistedID
}
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
insertCmd := &cobra.Command{
Use: "insert",
Short: "插入白板卡片",
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
不能同时使用。`,
Example: ` dws doc whiteboard insert --node DOC_ID
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
RunE: runWhiteboardInsert,
}
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
insertCmd.Flags().String(name, "", "--node 的兼容别名")
_ = insertCmd.Flags().MarkHidden(name)
}
DeclareLeafMetadata(insertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "whiteboard_insert",
CanonicalPath: "doc.whiteboard_insert",
CLIPath: "doc whiteboard insert",
PrimaryCLIPath: "doc whiteboard insert",
},
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
},
},
})
root.AddCommand(insertCmd)
return root
}
+72 -7
View File
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
if depth > 1 || latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
if pattern != "" {
return &CLIError{
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
// 默认路由:钉盘文件列表
if latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
},
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --folder <dentryUuid> --limit 20 --format json",
"dws drive list --latest 5 --format json",
},
},
},
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
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 多页扫描时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
toolArgs["maxResults"] = limit
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveCmd.AddCommand(
driveListCmd,
+39 -18
View File
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +209,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)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +220,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +233,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
if err != nil {
folderErr = err
@@ -249,6 +249,12 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
if ms, ok := driveItemModifiedMillis(item); ok {
item["sortTime"] = ms
} else {
item["sortTime"] = int64(0)
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
@@ -291,7 +297,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
return emitErr
}
return folderErr
@@ -324,18 +330,26 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
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 {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
if latest > 0 {
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
}
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
items = []map[string]any{}
}
+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"); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); 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, ""); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); 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, ""); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); 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, ""); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); 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)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); 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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); 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); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); 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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
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)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+199
View File
@@ -0,0 +1,199 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页)。
driveLatestScanMax = 1000
)
// validateDriveListLatest --latest 边界与互斥校验。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveLatestExclusiveError("limit", latest)
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveLatestExclusiveError("cursor", latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
if v, ok := item[k]; ok {
if ms, ok := toMillis(v); ok {
return ms, true
}
}
}
return 0, false
}
func toMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
// runDriveListLatest 钉盘单层 --latest 扫描。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
+3 -1
View File
@@ -66,6 +66,8 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
if driveWorkerContextErr(runCtx) != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+25 -39
View File
@@ -13,6 +13,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
var checks atomic.Int32
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
checks.Add(1)
return context.Canceled
})
var requests atomic.Int32
testseam.Swap(t, &driveRangeClient, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// 每次请求加小延迟,让总处理时间接近 deadline
time.Sleep(50 * time.Microsecond)
var start, end int64
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
}
if end >= int64(len(content)) {
end = int64(len(content)) - 1
}
resp := &http.Response{
StatusCode: http.StatusPartialContent,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
}
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
return resp, nil
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
t.Fatalf("downloadRangedParts context guard: %v", err)
}
// 多次尝试以确保覆盖(goroutine 调度非确定性)
for attempt := 0; attempt < 50; attempt++ {
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
cancel()
if checks.Load() != 1 {
t.Fatalf("worker context checks = %d, want 1", checks.Load())
}
if requests.Load() != 0 {
t.Fatalf("worker requests = %d, want 0", requests.Load())
}
}
+1
View File
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"enterprise_person_search": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
+3
View File
@@ -190,6 +190,9 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
if toolName == "enterprise_person_search" {
return true
}
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
+495 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ──────────────────────────────────────────────────────────
@@ -195,7 +196,72 @@ func newMailCommand() *cobra.Command {
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
mailboxSharedWithMeCmd := &cobra.Command{
Use: "shared-with-me",
Short: "查询共享给我的邮箱",
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
共享关系(relationships)取值:
LOGIN 登录(可登录该共享邮箱)
SEND_AS 代发(以该邮箱身份发送邮件)
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
返回字段:
total 可访问的共享账号总数
targets 可访问的共享账号列表`,
Example: ` dws mail mailbox shared-with-me
dws mail mailbox shared-with-me --limit 20 --offset 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if cmd.Flags().Changed("limit") {
limit, _ := cmd.Flags().GetInt("limit")
toolArgs["limit"] = limit
}
if cmd.Flags().Changed("offset") {
offset, _ := cmd.Flags().GetInt("offset")
toolArgs["offset"] = offset
}
if len(toolArgs) == 0 {
return callMCPTool("list_shared_with_me", nil)
}
return callMCPTool("list_shared_with_me", toolArgs)
},
}
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_shared_with_me",
CanonicalPath: "mail.list_shared_with_me",
CLIPath: "mail mailbox shared-with-me",
PrimaryCLIPath: "mail mailbox shared-with-me",
},
Description: "查询共享给我的邮箱",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询他人共享给当前用户的邮箱",
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
Examples: []string{"dws mail mailbox shared-with-me"},
},
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "limit", InterfaceType: "integer"},
{Name: "offset", Property: "offset", InterfaceType: "integer"},
},
},
})
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -2126,9 +2192,256 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageExportCmd := &cobra.Command{
Use: "export",
Short: "导出/备份邮件(EML格式)",
Long: `导出指定邮件为 EML 格式文件并保存到本地。
不指定 --filename 时,默认以邮件主题作为文件名。
文件保存在当前工作目录下,扩展名为 .eml。
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
注意:目前仅支持 100KB 以内的邮件导出。
编排流程:
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
2. 调用 export_message_mime 获取 EML 内容
3. 将 EML 内容原子写入本地文件`,
Example: ` dws mail message export --email user@company.com --id <messageId>
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
messageID := mustGetFlag(cmd, "id")
filename := mustGetFlag(cmd, "filename")
overwrite, _ := cmd.Flags().GetBool("overwrite")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
deps.Out.PrintKeyValue("email", email)
deps.Out.PrintKeyValue("messageId", messageID)
if filename != "" {
deps.Out.PrintKeyValue("filename", filename)
}
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
return nil
}
ctx := cmd.Context()
if filename == "" {
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
"email": email,
"messageId": messageID,
})
if err != nil {
return fmt.Errorf("获取邮件信息失败: %w", err)
}
var msgData map[string]any
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
data := msgData
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if msg, ok := data["message"].(map[string]any); ok {
data = msg
}
if subj, ok := data["subject"].(string); ok && subj != "" {
filename = subj
}
}
if filename == "" {
filename = messageID
}
}
filename = sanitizeMailFilename(filename)
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
"email": email,
"id": messageID,
})
if err != nil {
return fmt.Errorf("导出邮件失败: %w", err)
}
var exportData map[string]any
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
return fmt.Errorf("解析导出结果失败: %w", err)
}
if result, ok := exportData["result"].(map[string]any); ok {
exportData = result
}
emlContent, _ := exportData["emlContent"].(string)
if emlContent == "" {
return fmt.Errorf("导出结果为空: %s", exportText)
}
destPath := filename + ".eml"
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
if os.IsExist(err) {
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
}
return fmt.Errorf("保存文件失败: %w", err)
}
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
return nil
},
}
DeclareLeafMetadata(messageExportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "export_message_mime",
CanonicalPath: "mail.export_message_mime",
CLIPath: "mail message export",
PrimaryCLIPath: "mail message export",
},
Description: "导出/备份邮件为本地 EML 文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
},
Selection: contract.SelectionSpec{
AgentSummary: "导出邮件为本地 EML 备份",
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "filename", Property: "filename"},
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
},
},
})
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
messageShareToChatCmd := &cobra.Command{
Use: "share-to-chat",
Short: "[危险] 分享邮件至IM聊天",
Long: `将指定邮件分享到钉钉单聊。
参数说明:
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
--yes 确认执行此危险操作 (必填)
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
将展示风险提示并自动携带 sign 重新请求。`,
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2 --yes
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
mcpArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
}
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
mcpArgs["uids"] = parseRecipients(users)
}
yes, _ := cmd.Flags().GetBool("yes")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
deps.Out.PrintKeyValue("users", users)
}
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
return nil
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
)
}
ctx := cmd.Context()
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
if err != nil {
return fmt.Errorf("分享邮件失败: %w", err)
}
var firstResult map[string]any
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
return fmt.Errorf("解析分享结果失败: %w", err)
}
if result, ok := firstResult["result"].(map[string]any); ok {
firstResult = result
}
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
}
mcpArgs["sign"] = sign
return callMCPTool("share_message_to_chat", mcpArgs)
}
// firstResult is already a parsed object (possibly unwrapped from result).
return deps.Out.PrintJSON(firstResult)
},
}
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "share_message_to_chat",
CanonicalPath: "mail.share_message_to_chat",
CLIPath: "mail message share-to-chat",
PrimaryCLIPath: "mail message share-to-chat",
},
Description: "分享邮件至 IM 单聊",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
},
Selection: contract.SelectionSpec{
AgentSummary: "把邮件分享到钉钉单聊",
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "users", Property: "uids"},
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
},
},
})
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
@@ -3400,7 +3713,129 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
Example: ` dws mail calendar list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_calendars", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
DeclareLeafMetadata(calendarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendars",
CanonicalPath: "mail.list_mailbox_calendars",
CLIPath: "mail calendar list",
PrimaryCLIPath: "mail calendar list",
},
Description: "列出邮箱日历文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
},
Selection: contract.SelectionSpec{
AgentSummary: "列出邮箱可访问的日历文件夹",
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
Examples: []string{"dws mail calendar list --email user@company.com"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
},
},
})
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
return err
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": flagOrFallback(cmd, "id", "folder-id"),
"startTime": flagOrFallback(cmd, "start", "start-time"),
"endTime": flagOrFallback(cmd, "end", "end-time"),
}
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
toolArgs["cursor"] = cursor
}
return callMCPTool("list_mailbox_calendar_events", toolArgs)
},
}
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendar_events",
CanonicalPath: "mail.list_mailbox_calendar_events",
CLIPath: "mail calendar-event list",
PrimaryCLIPath: "mail calendar-event list",
},
Description: "查询邮箱日历文件夹时间范围内的日程",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询邮箱日历日程列表",
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "start", Property: "startTime", Required: boolPtr(true)},
{Name: "end", Property: "endTime", Required: boolPtr(true)},
{Name: "cursor", Property: "cursor"},
},
},
})
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
calendarEventCmd.AddCommand(calendarEventListCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
return root
}
@@ -3935,3 +4370,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
return nil
}
func sanitizeMailFilename(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "mail"
}
return name
}
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
var mailAtomicLink = os.Link
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
dir := filepath.Dir(path)
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = atomicRemove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("设置文件权限失败: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("写入数据失败: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("同步磁盘失败: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("关闭临时文件失败: %w", err)
}
if overwrite {
if err := atomicRename(tmpName, path); err != nil {
return fmt.Errorf("重命名文件失败: %w", err)
}
success = true
return nil
}
if err := mailAtomicLink(tmpName, path); err != nil {
return err
}
_ = atomicRemove(tmpName)
success = true
return nil
}
+2 -1
View File
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
newMarkdownDiffCmd(),
newMarkdownOverwriteCmd(),
newMarkdownPatchCmd(),
)
+491
View File
@@ -0,0 +1,491 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws markdown diff
// ──────────────────────────────────────────────────────────
// diffResult 是 diff 命令的输出结构。
type diffResult struct {
Mode string `json:"mode"`
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks int `json:"hunks"`
Diff string `json:"diff"`
}
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
var (
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = computeUnifiedDiff
)
// formatFileSize 返回人类可读的文件大小。
func formatFileSize(size int64) string {
if size >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
}
if size >= 1024 {
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
}
return fmt.Sprintf("%d B", size)
}
// checkFileSize 校验文件大小是否超过限制。
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxDiffFileSize {
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
}
return nil
}
// downloadRemoteContent 下载远端文件内容并返回其文本。
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
var text string
var err error
if versionNum > 0 {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
} else {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
"fileId": fileID,
})
}
if err != nil {
return "", err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return "", err
}
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
if err != nil {
return "", err
}
return string(content), nil
}
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
// 包级变量以便测试注入。
var diffDownloadLimited = defaultDiffDownloadLimited
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: diffDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
// Content-Length 预检:可在读取 body 前提前拦截超大文件
if resp.ContentLength > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
}
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
}
return data, nil
}
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
if len(out) == 0 {
return "", 0, 0, 0, false
}
text := string(out)
added, deleted, hunks := 0, 0, 0
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
inHunk := false
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "@@") {
hunks++
inHunk = true
continue
}
if !inHunk {
continue
}
if strings.HasPrefix(line, "+") {
added++
} else if strings.HasPrefix(line, "-") {
deleted++
}
}
changed := added > 0 || deleted > 0
return text, added, deleted, hunks, changed
}
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
info := fetchFileInfo(ctx, nodeID)
switch info.extension {
case "", "md", "markdown":
return nil
case "adoc":
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
case "axls":
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
case "amind", "adraw":
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
default:
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
}
}
// newMarkdownDiffCmd 创建 markdown diff 子命令。
func newMarkdownDiffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "diff",
Short: "比较 Markdown 内容差异",
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
模式:
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
remote_vs_remote: --version V1 (历史版本 vs 最新)
remote_vs_local: --file ./local.md (最新 vs 本地)
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
历史版本号通过 dws drive list --versions 获取。
--file 与 --version2 不能同时使用。
限制:
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
- 单侧文件大小上限: 10 MB
- 下载超时: 10 分钟
- diff 计算超时: 30 秒`,
Example: ` # 比较两个历史版本
dws markdown diff --node <dentryUuid> --version 3 --version2 5
# 历史版本 vs 最新版本
dws markdown diff --node <dentryUuid> --version 3
# 最新版本 vs 本地文件
dws markdown diff --node <dentryUuid> --file ./draft.md
# 历史版本 vs 本地文件
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
version1, _ := cmd.Flags().GetInt("version")
version2, _ := cmd.Flags().GetInt("version2")
localFile, _ := cmd.Flags().GetString("file")
contextLines, _ := cmd.Flags().GetInt("context")
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
if cmd.Flags().Changed("version") && version1 <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
}
if cmd.Flags().Changed("version2") && version2 <= 0 {
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
}
if contextLines < 0 {
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
}
// 互斥校验:--file 与 --version2 不能同时使用
if localFile != "" && version2 > 0 {
return fmt.Errorf("--file 与 --version2 不能同时使用")
}
// 模式判定
var mode string
if localFile != "" {
mode = "remote_vs_local"
} else {
mode = "remote_vs_remote"
}
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
if localFile == "" && version1 == 0 && version2 == 0 {
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
deps.Out.PrintKeyValue("模式", mode)
deps.Out.PrintKeyValue("节点ID", nodeID)
if version1 > 0 {
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
} else {
deps.Out.PrintKeyValue("左侧版本", "最新")
}
if localFile != "" {
deps.Out.PrintKeyValue("右侧", localFile)
} else if version2 > 0 {
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
} else {
deps.Out.PrintKeyValue("右侧版本", "最新")
}
return nil
}
// 下载超时 context(与项目其他下载命令一致:10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
defer cancel()
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
return err
}
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
if localFile != "" {
if err := checkFileSize(localFile); err != nil {
return err
}
}
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
isJSON := deps.Caller.Format() == "json"
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
// 下载左侧内容
progress("[1/3] 获取左侧内容...")
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
if err != nil {
return err
}
// 获取右侧内容
var rightContent string
if localFile != "" {
// remote_vs_local: 读取本地文件(大小已校验)
progress("[2/3] 读取本地文件...")
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("读取本地文件失败: %w", err)
}
rightContent = string(data)
} else {
// remote_vs_remote: 下载右侧远端内容
progress("[2/3] 获取右侧内容...")
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
if err != nil {
return err
}
}
// 计算 diff(带超时保护)
progress("[3/3] 计算差异...")
type diffOutput struct {
text string
added int
deleted int
hunks int
changed bool
}
resultCh := make(chan diffOutput, 1)
// Capture seams before the goroutine so test restorers cannot race
// against a still-running compute after the timeout path returns.
computeDiff := runMarkdownUnifiedDiff
marshalIndent := diffJSONMarshalIndent
go func() {
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
resultCh <- diffOutput{text, added, deleted, hunks, changed}
}()
select {
case res := <-resultCh:
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
result := diffResult{
Mode: mode,
Changed: changed,
AddedLines: added,
DeletedLines: deleted,
Hunks: hunks,
Diff: diffText,
}
// 输出
if isJSON {
data, err := marshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
deps.Out.PrintRaw(string(data))
} else {
deps.Out.PrintKeyValue("模式", result.Mode)
if result.Changed {
deps.Out.PrintKeyValue("是否有变更", "是")
} else {
deps.Out.PrintKeyValue("是否有变更", "否")
}
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
if result.Changed {
deps.Out.PrintRaw("")
deps.Out.PrintRaw(result.Diff)
}
}
case <-time.After(diffComputeTimeout):
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
}
return nil
},
}
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
// --node 隐藏别名(与 version/fetch 子命令一致)
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
_ = cmd.Flags().MarkHidden("url")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("node-id")
_ = cmd.Flags().MarkHidden("doc-id")
_ = cmd.Flags().MarkHidden("file-id")
RegisterCrossProductAliases(cmd)
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "markdown",
Name: "diff",
CanonicalPath: "markdown.diff",
CLIPath: "markdown diff",
PrimaryCLIPath: "markdown diff",
},
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "version", Property: "version", InterfaceType: "integer"},
{Name: "version2", Property: "version2", InterfaceType: "integer"},
{Name: "file", Property: "file"},
{Name: "context", Property: "context", InterfaceType: "integer"},
},
},
})
return cmd
}
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
// 探测失败返回零值,由调用方决定是否阻断。
type markdownFileInfo struct {
name string
extension string
}
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
return
}
data := resp
if result, ok := resp["result"].(map[string]any); ok {
data = result
}
if name, ok := data["name"].(string); ok {
info.name = name
}
if ext, ok := data["extension"].(string); ok {
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
}
return
}
func describeDingTalkDocType(ext string) string {
switch strings.ToLower(ext) {
case "adoc":
return "文档"
case "axls":
return "表格"
case "amind":
return "脑图"
case "adraw":
return "画图"
default:
return "文件"
}
}
+260 -10
View File
@@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"strconv"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "minutes",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
UseWhen: []string{
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
},
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
AvoidWhen: []string{
"要查看已有热词时改用 dws minutes hot-word list",
"要删除热词时改用 dws minutes hot-word delete",
},
Examples: []string{
"dws minutes hot-word add --words \"钉钉\"",
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户配置的所有听记热词列表。",
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
Examples: []string{"dws minutes hot-word list"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"要删除热词时改用 hot-word delete",
},
Examples: []string{"dws minutes hot-word list"},
},
},
})
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
hotWordDeleteCmd := &cobra.Command{
Use: "delete",
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
return err
}
return callMCPTool("delete_personal_hotword", map[string]any{
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
})
},
}
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "delete_personal_hotword",
CanonicalPath: "minutes.delete_personal_hotword",
CLIPath: "minutes hot-word delete",
PrimaryCLIPath: "minutes hot-word delete",
},
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "words", Property: "hotWordList"},
},
},
})
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
// ── replace-text 命令 ───────────────────────────────────────
replaceTextCmd := &cobra.Command{
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission。
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
// permission add — 对应 MCP 工具 add_member_permission
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
AvoidWhen: []string{
"要移除成员权限时改用 dws minutes permission remove",
"当前用户自己申请访问权限时改用 dws minutes permission apply",
"成员、权限策略或听记 id 未确认时不要添加",
},
Examples: []string{
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
AvoidWhen: []string{
"要添加权限时改用 permission add",
"当前用户自己申请访问权限时改用 permission apply",
"成员或听记 id 未确认时不要移除",
},
Examples: []string{
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
// permission apply — 对应 MCP 工具 apply_minutes_permission
permissionApplyCmd := &cobra.Command{
Use: "apply",
Short: "为当前用户申请听记权限",
Long: `为当前登录用户申请指定听记的权限。
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
权限类型 (--policy):
2 = 可编辑
3 = 可查看/下载
4 = 仅查看`,
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
dws minutes permission apply --id <taskUuid> --policy 2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
return callMCPTool("apply_minutes_permission", map[string]any{
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
"policyId": float64(policyID),
})
},
}
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "apply_minutes_permission",
CanonicalPath: "minutes.apply_minutes_permission",
CLIPath: "minutes permission apply",
PrimaryCLIPath: "minutes permission apply",
},
Description: "为当前登录用户申请指定听记的权限。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
AvoidWhen: []string{
"所有者批量给他人加权限时改用 permission add",
"要移除他人权限时改用 permission remove",
},
Examples: []string{
"dws minutes permission apply --id <taskUuid> --policy 4",
"dws minutes permission apply --id <taskUuid> --policy 2",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "taskUuid"},
{Name: "policy", Property: "policyId"},
},
},
})
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("url")
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
// ── tag 子组 ────────────────────────────────────────────────
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
// ── audio-memo 子组 ────────────────────────────
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoListCmd := &cobra.Command{
Use: "list",
Short: "查询语音备忘列表",
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
Example: ` dws minutes audio-memo list
dws minutes audio-memo list --max 500
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
dws minutes audio-memo list --cursor 1740000000000`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
max, _ := cmd.Flags().GetFloat64("max")
if max <= 0 || max > 1000 {
return fmt.Errorf("flag --max must be between 1 and 1000")
}
toolArgs["pageSize"] = max
if cmd.Flags().Changed("cursor") {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return fmt.Errorf("flag --cursor must be >= 0")
}
toolArgs["cursor"] = float64(cursor)
}
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
loc := time.FixedZone("Asia/Shanghai", 8*3600)
var startMs, endMs int64
if startStr != "" {
var err error
startMs, err = parseISOTimeToMillis("start", startStr)
if err != nil {
return err
}
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
}
if endStr != "" {
var err error
endMs, err = parseISOTimeToMillis("end", endStr)
if err != nil {
return err
}
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
}
if startStr != "" && endStr != "" {
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
}
return callMCPToolUnescaped("list_audio_memos", toolArgs)
},
}
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "list_audio_memos",
CanonicalPath: "minutes.list_audio_memos",
CLIPath: "minutes audio-memo list",
PrimaryCLIPath: "minutes audio-memo list",
},
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
AvoidWhen: []string{
"要查听记列表改用 minutes list",
"只要某篇听记的音频地址改用 minutes get audio",
},
Examples: []string{
"dws minutes audio-memo list",
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "max", Property: "pageSize"},
{Name: "cursor", Property: "cursor"},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
},
},
})
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}

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