Compare commits

..
Author SHA1 Message Date
chichuan 82d02096f7 Merge pull request #833 from DingTalk-Real-AI/codex/changelog-v1.0.55-promote-beta.8
docs(release): promote v1.0.55-beta.8 baseline
2026-07-30 11:00:51 +08:00
chichuan ad08b6b499 docs(release): promote v1.0.55-beta.8 2026-07-30 10:32:20 +08:00
github-actions[bot] 250aab3ef1 chore: update beta formula for v1.0.55-beta.8 [skip ci] 2026-07-30 02:28:33 +00:00
chichuan e36b6dc049 Merge pull request #832 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.8
docs(release): add v1.0.55-beta.8 notes
2026-07-30 10:19:15 +08:00
chichuan d9d62fb2f7 docs(release): add v1.0.55-beta.8 notes 2026-07-30 09:55:16 +08:00
github-actions[bot] f9f61a4cc6 Merge pull request #825 from DingTalk-Real-AI/codex/changelog-v1.0.55
docs(release): add v1.0.55 stable notes
2026-07-30 09:39:02 +08:00
chichuan 8d22cd553a docs(release): add v1.0.55 stable notes 2026-07-29 20:42:19 +08:00
github-actions[bot] 4f31863aae chore: update beta formula for v1.0.55-beta.7 [skip ci] 2026-07-29 10:19:40 +00:00
chichuan 6de2bf1518 docs(release): 合入 beta.7 发布说明(风险等级:低)
发布模块:CHANGELOG。补充 v1.0.55-beta.7 的完整变更说明,并保留失败 beta.6 的审计记录。风险等级:低。
2026-07-29 18:09:24 +08:00
chichuan 78b724480e docs(release): 补充 beta.7 完整发布说明(风险等级:低) 2026-07-29 18:02:40 +08:00
github-actions[bot] 4724c30f4b Merge pull request #821 from typefield/agent/restore-shared-account-rule
fix(skills): restore multi-account safety rule in dws-shared SKILL.md
2026-07-29 17:56:16 +08:00
玉澜 76b9f1536a test(app): pin multi-account safety rule in embedded dws-shared skill
Replace the CI classifier change with a real PR-level regression
contract: materialize the embedded multi skill source and assert
dws-shared/SKILL.md keeps the 禁止选择第一项、最近登录或最近使用账号 rule
that the MultiSkill e2e release gate requires. The new test file also
makes the revision full-suite so all quality gates run on this PR.
2026-07-29 17:42:04 +08:00
玉澜 809b9b3570 ci: classify skills/ changes as docs-only for fast path
Skill markdown files are agent documentation embedded at build time;
they carry no Go code changes. Without this classification a one-line
SKILL.md edit triggers the full -race test suite on internal/app and
reverse dependencies, which exceeds the 8m job timeout and fails CI
deterministically.
2026-07-29 17:36:56 +08:00
玉澜 0be5b73518 fix(skills): restore multi-account safety rule in dws-shared SKILL.md
Commit dc20ddec dropped the 禁止选择第一项、最近登录或最近使用账号 rule
from dws-shared/SKILL.md during the multi-skill refactor while the
MultiSkill e2e contract still asserts it there, blocking the
v1.0.55-beta.6 release run. Restore the rule as a mandatory-contract
bullet pointing at dingtalk-profile/SKILL.md for the full selection and
cross-org rules.
2026-07-29 17:19:31 +08:00
chichuan a637a44b7a docs(release): 恢复 beta.6 main admission(风险等级:低)
明确 beta.6 五个 PR 审计范围,并由真实用户合入以触发 main CHANGELOG fast-path CI。
2026-07-29 16:52:33 +08:00
github-actions[bot] 579eed81d9 Merge pull request #818 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.6
docs(release): prepare v1.0.55-beta.6 changelog
2026-07-29 16:38:54 +08:00
chichuan c68d9facb2 docs(release): 补充 CHANGELOG beta.6 五项合入说明(风险等级:低) 2026-07-29 16:33:48 +08:00
github-actions[bot] 1f9138e99a Merge pull request #621 from typefield/agent/sync-wukong-multi-skill
feat(skills): add  multi-skill framework to DWS
2026-07-29 16:24:53 +08:00
玉澜 c3fd814630 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
2026-07-29 16:08:16 +08:00
github-actions[bot] 5922a0717a Merge pull request #816 from wxianfeng/feature/aone82250541-agent-product
feat: support configurable Agent Product identity
2026-07-29 16:04:24 +08:00
玉澜 c5bc1fdad4 Merge remote-tracking branch 'typefield/agent/sync-wukong-multi-skill' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_parameter_bindings.json
2026-07-29 15:51:04 +08:00
玉澜 9567cfd3d8 fix(review): align wukong port with upstream behavior and PR #621 review findings
Must-fix: drive permission apply now gates on confirmDangerousAction and
declares confirmation=user_required, matching its help-text promise.

Wukong parity restored: formula-verify --exit-on-error (payload-parsing
exit path) and --targets conflict error, sheet info --include, chat
location/profile message types, search-advanced wukong flag aliases,
and a dedicated drive download-version leaf replacing the removed
polymorphic download --version.

Consistency fixes: transfer-owner --node/--workspace XOR and JSON-aware
dry-run after --yes validation; drive list --versions rejects
--depth/--pattern instead of misleading depth errors; depth BFS resumes
rate-limited folders from the failed page cursor to avoid duplicates;
doc style cover upload honors cmd.Context() and a 20 MiB size cap; chat
user-settings set validates per-item openConversationId and is
risk=medium.

Hardened the skill static audit to scan fenced code blocks and reject
unknown subcommands on group commands, fixing the stale aitable/drive
doc examples it exposed. Added CHANGELOG entry and coverage tests for
all changed statements plus previously untested ported commands.
2026-07-29 15:34:59 +08:00
chichuan 1180510f40 merge(agent-product): 同步 main 并解决 CHANGELOG 冲突(风险等级:高)
保留 #816 的 Agent Product 身份说明与 main 中已合入的 Shortcut 修复条目,并完成全仓测试、构建及 Schema 生成漂移校验。
2026-07-29 15:19:28 +08:00
chichuan 2456660780 test(chat): 补齐文字表情跨平台覆盖(风险:低)
让 update-text-emotion 映射与缺参测试进入 Darwin/Windows coverage 矩阵,并移除已由 Cobra 必填门禁覆盖的不可达重复校验。
2026-07-29 14:36:41 +08:00
chichuan c3dbe866c4 feat(chat): 补齐文字表情原地更新契约(风险:低)
基于 PR #621 现有 update-text-emotion 实现,补齐七参数 RPC 映射、Cobra/Schema 必填约束、mono Skill、CHANGELOG 与别名/缺参回归测试。
2026-07-29 14:17:03 +08:00
wxianfeng 81f130c483 fix: address agent product review feedback 2026-07-29 13:56:25 +08:00
玉澜 6b99685594 fix(schema): bump runtime-surface completeness source_tools to 839
The 26 newly registered commands raised the registry count to 839, but
runtime-surface-completeness.json still declared source_tools=813, so
check-schema-catalog.sh failed the Policy job ("runtime-surface
completeness source must remain unreviewed and interface-free"). The 26
tools are all reviewed in metadata/selection sources, so the unreviewed
71-tool list is unchanged; regenerate dependent schema artifacts.
2026-07-29 13:51:25 +08:00
玉澜 9d59550890 test(helpers): cover new drive/doc-style/sheet/chat commands to 100% changed-code coverage
The CI platform coverage gate enforces 100% coverage of changed
statements via tests named TestCrossPlatformCoverage*/TestAllShortcuts.
Add unit tests for drive list --depth BFS (pagination, rate-limit retry,
dedup, truncation, SIGINT, anomalies), drive list --versions/transfer-
owner/cover/revert paths, doc style cover upload flow, sheet
formula-verify target parsing, and chat group user-settings validation.
Also drop an unreachable resourceID guard in uploadDocStyleImage.
2026-07-29 13:29:22 +08:00
玉澜 83f13d8e11 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-29 12:25:45 +08:00
wxianfeng 86bce64cc8 test: cover agent product header branches 2026-07-29 12:06:43 +08:00
玉澜 68e1a78810 feat(cli): port drive list --depth and doc style, register all new commands in Schema
- Port drive list --depth N BFS recursive listing (pan + workspace routes,
  rate-limit requeue, SIGINT partial emit, --pattern/--quiet)
- Port doc style cover set/clear, background set/clear, get with local
  image validation and attachment-upload subflow
- Register all 26 newly ported commands in schema_command_registry with
  reviewed metadata/selection hints instead of exclusions (813->839 tools)
- Review fixes: drive list --node usage text no longer implies required
  in agent schema; remove broken formula-verify --exit-on-error; error on
  --range without --sheet-id; portable stdin read; drop local --yes
  shadowing root -y on drive revert/transfer-owner; use
  confirmDangerousAction for non-delete confirms; explicit
  recursiveChange=false now transmitted; sheet version revert and
  comment delete moved into sheet confirmationGuards registry
2026-07-29 11:57:57 +08:00
玉澜 e63a4bdf47 feat(cli): add chat group get-mute-config command from wukong develop 2026-07-29 11:18:49 +08:00
github-actions[bot] 6ab01a365e Merge pull request #815 from DingTalk-Real-AI/codex/im-shortcut-optimization
feat(chat): harden and publish IM shortcuts
2026-07-29 11:05:56 +08:00
玉澜 d855edaad2 feat(cli): add chat message update-text-emotion command 2026-07-29 11:03:04 +08:00
玉澜 75fce5c4ff Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 10:53:12 +08:00
玉澜 d28a50c0f4 fix(cli): resolve schema parameter mapping for drive download
Remove --version flag from drive download (polymorphic tool dispatch
incompatible with schema validation). Regenerate schema catalog and
add new commands to schema_command_exclusions.json.
2026-07-29 10:12:15 +08:00
玉澜 bbecd2f3a6 style: gofmt chat.go 2026-07-29 09:23:27 +08:00
玉澜 a705c9de0b feat(cli): implement wukong-internal commands in open-source CLI
Port 19 command leaves from wukong internal CLI:
- drive star add/remove/list (文档收藏)
- drive cover (节点封面)
- drive revert (文件版本回滚)
- drive list --versions / download --version (文件历史版本)
- drive permission transfer-owner/apply-info/apply
- sheet version save/list/revert
- sheet formula-verify
- sheet comment list/create/reply/update/delete
- chat group user-settings query/set

Restore corresponding skill docs and register commands in schema
exclusions pending Schema review.
2026-07-29 01:06:36 +08:00
玉澜 f5d57c2e07 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync 2026-07-29 00:32:22 +08:00
玉澜 967cf26d44 fix(skills): remove commands absent from open-source CLI
Remove references to wukong-internal-only commands that fail CI
Interface Integrity: drive permission transfer-owner/apply/apply-info,
drive star/cover/revert/list --versions, sheet comment/formula-verify/
version, chat group user-settings. Delete sheet-comment.md and
sheet-version.md entirely.
2026-07-28 22:52:46 +08:00
玉澜 d31cae2b0c Revert "docs(skills): add create→transfer-owner bridge for group owner scenario"
This reverts commit 4e71f56f97.
2026-07-28 22:04:21 +08:00
wxianfeng e998e2609d feat: support agent product identity to #82250541 2026-07-28 21:46:20 +08:00
玉澜 4e71f56f97 docs(skills): add create→transfer-owner bridge for group owner scenario
group create does not support --owner; agents need an explicit pointer
to transfer-owner when users ask to specify a group owner at creation.
2026-07-28 21:44:59 +08:00
玉澜 69b8df3e40 fix(skills): reconcile wukong sync with latest main CLI surface
Restore capabilities now supported on main (doc read --scope/--tags,
drive upload --node overwrite, chat category, dingtalk-markdown routing),
remove commands still absent from the open-source CLI (calendar event
instances, sheet info --include, chat group create --owner), remap
folded services (attendance/ding/oa/report/sheet) to dingtalk-misc in
the shortcut generator, and regenerate shortcut sections and schema
metadata.
2026-07-28 20:56:37 +08:00
玉澜 8d988bc350 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	skills/multi/dingtalk-aitable/SKILL.md
#	skills/multi/dingtalk-attendance/SKILL.md
#	skills/multi/dingtalk-calendar/SKILL.md
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
#	skills/multi/dingtalk-contact/SKILL.md
#	skills/multi/dingtalk-contact/references/contact.md
#	skills/multi/dingtalk-ding/SKILL.md
#	skills/multi/dingtalk-doc/SKILL.md
#	skills/multi/dingtalk-doc/references/doc.md
#	skills/multi/dingtalk-doc/references/doc/doc-comment.md
#	skills/multi/dingtalk-doc/references/doc/doc-read.md
#	skills/multi/dingtalk-drive/SKILL.md
#	skills/multi/dingtalk-drive/references/drive.md
#	skills/multi/dingtalk-mail/SKILL.md
#	skills/multi/dingtalk-minutes/SKILL.md
#	skills/multi/dingtalk-oa/SKILL.md
#	skills/multi/dingtalk-report/SKILL.md
#	skills/multi/dingtalk-sheet/SKILL.md
#	skills/multi/dingtalk-todo/SKILL.md
#	skills/multi/dingtalk-todo/references/todo.md
#	skills/multi/dingtalk-wiki/SKILL.md
#	skills/multi/dws-shared/SKILL.md
2026-07-28 20:25:16 +08:00
玉澜 dc20ddecf6 feat(skills): sync wukong 13-sub-skill multi layout with open-source cleanup
Replace skills/multi with wukong's consolidated structure (long-tail
products folded into dingtalk-misc), keeping GitHub-only skills
(dingtalk-dev/event/pat/profile/skill). Prune MCP-only product refs and
align all documented commands/flags with the open-source Cobra tree:
remove markdown/*, drive task get, drive version flags, doc read
--scope, --async modes, retired conference/chat-file-upload mentions.
2026-07-28 20:20:12 +08:00
玉澜 a09790fc3f Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	test/skill_static/skill_static_test.go
2026-07-28 18:10:50 +08:00
玉澜 52045fb290 Merge upstream/main into agent/sync-wukong-multi-skill 2026-07-16 18:17:17 +08:00
玉澜 275c3430b8 fix(skills): reconcile multi-skill runtime contracts 2026-07-15 10:26:51 +08:00
玉澜 56116bf99e feat(skills): align Wukong multi-skill docs 2026-07-15 01:17:45 +08:00
299 changed files with 55371 additions and 10444 deletions
+83 -2
View File
@@ -6,10 +6,91 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.55-beta.8] - 2026-07-30
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
guarded release delivery. It carries no new product-facing command behavior;
the new version is required because the published beta.7 artifacts succeeded
on GitHub, npm, and Homebrew, but its enabled optional Gitee mirror failed and
left that Release run ineligible for stable promotion.
### Changed
- **Complete promotion evidence** — republishes the validated v1.0.55 command, Runtime Schema, Skill, authentication, and projection changes with the optional Gitee upload fallback disabled, so the release can produce one successful auditable delivery proof before stable promotion.
## [1.0.55] - 2026-07-30
This release promotes the validated `v1.0.55-beta.8` baseline to stable. It
expands the public Workspace command surface and personal event consumption,
makes the full built-in shortcut catalog available to Agents, and hardens
multi-account routing, authentication compatibility, command safety, and
response projection across the CLI.
### Added
- **Broader Workspace command surface** (#621, #676) — adds roughly 30 reviewed Drive, Doc, Sheet, and Chat leaf commands synchronized from Wukong, including Drive version and permission operations, document styling, Sheet comment/version/formula verification, and in-place text-emotion updates. A reusable declarative `LeafSpec` framework now delivers command identity, safety, selection, and guarded Help metadata consistently.
- **Complete Agent-visible shortcut delivery** (#802, #815) — publishes all 210 built-in shortcuts as reviewed Runtime Schema leaves across 16 products, including 88 validated Chat shortcuts, with executable paths, parameters, constraints, selection guidance, dry-run capabilities, and runtime-aligned confirmation semantics.
- **Expanded enterprise and event capabilities** (#790) — adds the HR Brain talent-pool, employee-profile, and structured-search command families; `dws mcp url get` resolves MCP Market endpoints; personal event consumption supports eight additional IM event keys, multi-key consumers, and targeted shutdown.
- **Agent integration identity** (#804, #816) — adds validated `DWS_AGENT_HOST` and `DWS_AGENT_PRODUCT` labels for observability and product attribution while keeping them separate from authentication and authorization.
### Changed
- **Progressive multi-Skill guidance and account safety** (#621, #821) — reorganizes bundled product guidance for progressive discovery and restores the mandatory rule that Agents must not guess an account when a multi-account organization has no unique current default.
- **Supported Chat file delivery** — retires the legacy AppKey/AppSecret-backed `chat media upload` command from discovery and routes local files through `chat message send --msg-type file --file-path`, while callers with an existing media ID can continue sending images directly.
- **Guarded release delivery** (#791) — strengthens immutable GitHub, npm, Homebrew, optional mirror, recovery, and version-allocation checks while keeping beta and stable publication role-gated and auditable.
### Fixed
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
- **Shortcut and message projection correctness** (#706, #783, #795) — prevents successful read shortcuts from silently projecting non-empty backend responses to empty results, renders rich, forwarded, and encrypted message forms safely, and fixes group-bot, bot-search, mail-thread, media-ID alias, and Todo paging response handling.
- **Command contract edge cases** (#803) — makes approval revocation and document rollback honor dry-run before confirmation or preflight, fixes Drive and Doc rename semantics, restores Drive-specific metadata, and validates Todo reminder rules.
- **Authentication and external-contact compatibility** (#756, #757) — migrates legacy global and organization-scoped credentials without cross-account token borrowing, preserves contacts that expose only `openDingTalkId`, and aligns message-resource flags with message-list output fields.
## [1.0.55-beta.7] - 2026-07-29
This beta supersedes the unpublished `v1.0.55-beta.6` candidate and packages
PRs #621, #676, #757, #815, #816, and #821. It restores the mandatory
multi-account safety rule caught by the sealed-release E2E gate while retaining
the reviewed Wukong capability and multi-Skill synchronization, declarative
command and Schema delivery, hardened Chat shortcuts, external contact
resolution, and Agent product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
- **Multi-account Skill safety contract** (#821) — restores the mandatory rule that an Agent must never choose the first, most recently logged-in, or most recently used account when an organization has multiple accounts without one unique `isOrgCurrent=true` default. A PR-level embedded-Skill regression test now catches removal before the full sealed-release E2E gate.
## [1.0.55-beta.6] - 2026-07-29
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
capability and multi-Skill synchronization, declarative command and Schema
delivery, hardened Chat shortcuts, external contact resolution, and Agent
product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
## [1.0.55-beta.5] - 2026-07-28
+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.55-beta.5"
version "1.0.55-beta.8"
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.55-beta.5/dws-darwin-arm64.tar.gz"
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-arm64.tar.gz"
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
end
def install
+38 -1
View File
@@ -5,7 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -13,6 +14,42 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
For QwenWork, report the dimensions separately:
```bash
DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
+12 -7
View File
@@ -24,6 +24,7 @@ import (
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
@@ -32,23 +33,27 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
Example: "qwenwork_cloud",
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-provided observation
// label. CR/LF is rejected before trimming so it can never be hidden at the
// edge of a value. An unset or whitespace-only value means "do not emit".
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.TrimSpace(raw)
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
@@ -59,7 +64,7 @@ func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+14 -4
View File
@@ -21,6 +21,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -32,12 +33,14 @@ func TestParseAgentHost(t *testing.T) {
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
@@ -64,6 +67,12 @@ func TestParseAgentHost(t *testing.T) {
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
@@ -91,6 +100,7 @@ func TestParseAgentHost(t *testing.T) {
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
+74
View File
@@ -0,0 +1,74 @@
// 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 (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Example: "qwenwork",
})
}
// parseAgentProduct converts the reusable package error into the CLI's stable
// structured validation error without exposing the untrusted raw value.
func parseAgentProduct(raw string) (string, error) {
value, err := agentproduct.Parse(raw)
if err != nil {
return "", invalidAgentProductError()
}
return value, nil
}
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
if headers == nil {
headers = make(map[string]string)
}
headers[agentproduct.HeaderName] = value
return headers
}
+267
View File
@@ -0,0 +1,267 @@
// 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 (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
}
}
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
const invalidValue = "DO_NOT ECHO"
got, err := parseAgentProduct(invalidValue)
if got != "" {
t.Fatalf("parseAgentProduct() = %q, want empty", got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_product" {
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
if got := headers["x-enterprise-header"]; got != "preserved" {
t.Fatalf("enterprise header = %q, want preserved", got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
}
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "invalid product")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
}
}
+2
View File
@@ -17,6 +17,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -346,6 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
t.Setenv(agentproduct.EnvName, "")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
@@ -0,0 +1,30 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 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
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
t.Fatalf("materialize embedded multi skill source: %v", err)
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dws-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)
}
}
+2 -1
View File
@@ -570,7 +570,8 @@ func handlePatAuthCheck(
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// edition.MergeHeaders and surfaced in hostControl for traceability.
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+5 -3
View File
@@ -30,6 +30,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -1006,10 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType — the open-source build pins that to the
// literal edition.DefaultOSSClawType value ("openClaw").
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
@@ -1053,7 +1055,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
t.Fatalf("hostControl.clawType = %q, want openClaw (open-source edition default)", got)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
+14 -13
View File
@@ -27,11 +27,11 @@ import (
//
// Decision rule:
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST
// be the exact value the CLI actually injects on the wire into the
// `claw-type` HTTP header. The open-source build pins that to
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
// is no per-spawn env override.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -48,15 +48,16 @@ func hostControlProviderFromEnv() string {
return effectiveClawType()
}
// effectiveClawType returns the literal value that MergeHeaders will
// inject into outbound `claw-type` headers. Going through the edition
// hook (instead of a hard-coded constant) keeps this site correct for
// downstream editions that override MergeHeaders.
// effectiveClawType resolves the literal value injected into outbound
// `claw-type` headers without invoking credential hooks from PAT error
// serialization. MergeHeaders implementations that set claw-type must satisfy
// the edition contract that this value is independent of the base map.
func effectiveClawType() string {
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
return v
headers := make(map[string]string)
if h := edition.Get(); h != nil {
if h.MergeHeaders != nil {
headers = h.MergeHeaders(headers)
}
}
return edition.DefaultOSSClawType
return resolveEffectiveAgentProduct(headers)
}
+5 -1
View File
@@ -41,6 +41,7 @@ import (
"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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -353,12 +354,15 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Validate the optional observation label before any edition hook
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
+19 -5
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -1004,10 +1005,10 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which the
// open-source edition pins to edition.DefaultOSSClawType via the
// MergeHeaders hook below) and it does NOT influence the host-owned
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
sessionID = os.Getenv(envDWSSessionID)
@@ -1056,7 +1057,7 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
@@ -1067,9 +1068,22 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
return headers
}
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 120; got != want {
if got, want := int(product["count"].(float64)), 124; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 813,
"tools_with_metadata": 813,
"tools_with_agent_summary": 813,
"tools_with_use_when": 813,
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"surface_tools": 840,
"tools_with_metadata": 840,
"tools_with_agent_summary": 840,
"tools_with_use_when": 840,
"tools_with_avoid_when": 840,
"tools_with_examples": 840,
"tools_with_interface_mode": 840,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+741 -12
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:a933860d7efdc55d1868cb1efe8ff56c493a48cbffae246e2013f4394e70259a",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:c71b9bacfe2d73f5ad45151fada6fdc18bac69b266058a8b05647b6847c99835",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"surface_products": 26,
"surface_tools": 813,
"tools_with_agent_summary": 813,
"tools_with_metadata": 813,
"surface_tools": 840,
"tools_with_agent_summary": 840,
"tools_with_metadata": 840,
"unmatched_skill_tools": 122,
"version": 1
},
@@ -7722,7 +7722,7 @@
"id": "chat",
"name": "群聊 / 消息 / 机器人",
"runtime": true,
"tool_count": 120,
"tool_count": 124,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -7907,6 +7907,60 @@
"已有文字表情定义并要附加到消息时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"管理员级群功能开关用 chat group update-settings"
],
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_name": "query",
"cli_path": "chat group user-settings query",
"confirmation": "not_required",
"description": "批量查询当前用户的群会话设置",
"effect": "read",
"group": "group.user-settings",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "batch_query_group_chat_settings",
"primary_cli_path": "chat group user-settings query",
"reviewed": true,
"risk": "low",
"title": "批量查询当前用户的群会话设置",
"use_when": [
"用户说 看下这些群我的置顶和免打扰设置"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"单个群昵称优先 chat group update-nick"
],
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_name": "set",
"cli_path": "chat group user-settings set",
"confirmation": "not_required",
"description": "批量更新当前用户的群会话设置",
"effect": "write",
"group": "group.user-settings",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "batch_update_group_chat_settings",
"primary_cli_path": "chat group user-settings set",
"reviewed": true,
"risk": "medium",
"title": "批量更新当前用户的群会话设置",
"use_when": [
"用户说 把这些群都设为免打扰/置顶"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "把多条消息合并转发到目标会话",
@@ -8332,6 +8386,33 @@
"需要生成群邀请链接或设置有效期时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询群用户禁言配置(禁言黑名单/全员禁言白名单)",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"
],
"canonical_path": "chat.get_group_mute_config",
"cli_name": "get-mute-config",
"cli_path": "chat group get-mute-config",
"confirmation": "not_required",
"description": "查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。\n返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。",
"effect": "read",
"group": "group",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "get_group_mute_config",
"primary_cli_path": "chat group get-mute-config",
"reviewed": true,
"risk": "low",
"title": "查询群用户禁言配置",
"use_when": [
"用户说 看下群里谁被禁言/禁言配置"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询指定会话所属的自定义会话分组",
@@ -11166,6 +11247,34 @@
"已有 bizId 并需要追加内容或结束流式输出时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "把消息上已有的文字表情原地替换为新的文字表情",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"消息上还没有文字表情时使用 chat message add-text-emotion",
"只需清除文字表情时使用 chat message remove-text-emotion"
],
"canonical_path": "chat.update_text_emotion",
"cli_name": "update-text-emotion",
"cli_path": "chat message update-text-emotion",
"confirmation": "not_required",
"description": "更新消息的文字表情回应",
"effect": "write",
"group": "message",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI calls im/update_text_emotion, which is proven by dws-wukong develop@30f13f02 but absent from the pinned MCP metadata snapshot; no pinned interface_ref can represent the command yet.",
"name": "update_text_emotion",
"primary_cli_path": "chat message update-text-emotion",
"reviewed": true,
"risk": "low",
"title": "更新消息的文字表情回应",
"use_when": [
"需要更新消息的状态文字或表情,并避免先移除再添加造成闪烁和两次网络调用时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "不可逆地把已有普通群升级为外部群",
@@ -14238,7 +14347,7 @@
"id": "doc",
"name": "钉钉文档管理",
"runtime": true,
"tool_count": 55,
"tool_count": 60,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -14687,6 +14796,33 @@
"准备读内容前必须先看 contentType/extension 以路由到 read/sheet/aitable/download 时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "读取文档当前封面与背景配置(只读)",
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"修改用 cover set / background set"
],
"canonical_path": "doc.get_document_style",
"cli_name": "get",
"cli_path": "doc style get",
"confirmation": "not_required",
"description": "读取钉钉文档当前的封面与背景配置(只读,单接口收口 get_document_style)。",
"effect": "read",
"group": "style",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "get_document_style",
"primary_cli_path": "doc style get",
"reviewed": true,
"risk": "low",
"title": "读取文档封面/背景 (只读)",
"use_when": [
"用户说 看下文档现在的封面/背景"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "根据 taskId 查询文档导入任务的执行结果",
@@ -15536,6 +15672,114 @@
"当你在做重大改动前后、想手动打一个可回滚的版本存档点时使用;输入 node,会实际为该文档保存一个当前内容的历史版本快照。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "清除钉钉文档背景",
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"设置背景用 background set"
],
"canonical_path": "doc.style_background_clear",
"cli_name": "clear",
"cli_path": "doc style background clear",
"confirmation": "not_required",
"description": "清除文档背景",
"effect": "write",
"group": "style.background",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "style_background_clear",
"primary_cli_path": "doc style background clear",
"reviewed": true,
"risk": "low",
"title": "清除文档背景",
"use_when": [
"用户说 去掉文档背景色"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "设置钉钉文档背景纯色(#RRGGBB)",
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"背景不支持图片;封面用 cover set"
],
"canonical_path": "doc.style_background_set",
"cli_name": "set",
"cli_path": "doc style background set",
"confirmation": "not_required",
"description": "为钉钉文档设置背景纯色(--color)。背景仅支持纯色,不支持背景图片上传(对齐前端能力)。",
"effect": "write",
"group": "style.background",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "style_background_set",
"primary_cli_path": "doc style background set",
"reviewed": true,
"risk": "low",
"title": "设置文档背景纯色",
"use_when": [
"用户说 给文档设置背景色"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "移除钉钉文档封面",
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"设置封面用 cover set"
],
"canonical_path": "doc.style_cover_clear",
"cli_name": "clear",
"cli_path": "doc style cover clear",
"confirmation": "not_required",
"description": "移除文档封面",
"effect": "write",
"group": "style.cover",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "style_cover_clear",
"primary_cli_path": "doc style cover clear",
"reviewed": true,
"risk": "low",
"title": "移除文档封面",
"use_when": [
"用户说 去掉文档封面"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "设置钉钉文档顶部封面图(外链或本地图片上传)",
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"读取当前封面用 doc style get;移除封面用 cover clear"
],
"canonical_path": "doc.style_cover_set",
"cli_name": "set",
"cli_path": "doc style cover set",
"confirmation": "not_required",
"description": "为钉钉文档设置顶部封面图,可指定竖直显示位置。图片来源二选一:--image 外链或 --file 本地文件。",
"effect": "write",
"group": "style.cover",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "style_cover_set",
"primary_cli_path": "doc style cover set",
"reviewed": true,
"risk": "low",
"title": "设置文档封面",
"use_when": [
"用户说 给文档设置封面/换个封面图"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "使用指定模板创建新文档",
@@ -15929,7 +16173,7 @@
"id": "drive",
"name": "钉盘文件管理",
"runtime": true,
"tool_count": 32,
"tool_count": 41,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -15965,6 +16209,33 @@
"用户说把某篇文档分享给某人看/可编辑时(含「我的文档」下的节点)"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "向审批人发起文档权限申请(会真实通知审批人)",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"先用 apply-info 查可申请角色与审批人;未经用户确认不得自行提交"
],
"canonical_path": "drive.apply_permission",
"cli_name": "apply",
"cli_path": "drive permission apply",
"confirmation": "user_required",
"description": "向指定节点的审批人发起权限申请。建议先用 apply-info 获取可申请角色与审批人。\n\n注意: 本命令会真实通知审批人,Agent 必须先获得用户明确同意后再执行。",
"effect": "write",
"group": "permission",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "apply_permission",
"primary_cli_path": "drive permission apply",
"reviewed": true,
"risk": "medium",
"title": "发起权限申请",
"use_when": [
"用户确认要为无权限文档发起权限申请"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "提交文件上传",
@@ -16160,6 +16431,61 @@
"已确认 contentType 非 ALIDOC,需要落盘本地查看时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"下载最新版本用 drive download",
"在线文档(adoc)历史版本用 doc version 系列命令",
"在线表格(axls)历史版本用 sheet version 系列命令"
],
"canonical_path": "drive.download_file_version",
"cli_name": "download-version",
"cli_path": "drive download-version",
"confirmation": "not_required",
"description": "下载钉盘文件的指定历史版本到本地(两步下载流程)。\n\n仅适用于普通文件(如 pdf、docx、xlsx、png 等):\n 钉钉在线文档(adoc)请使用 dws doc version 系列命令\n 钉钉在线表格(axls)请使用 dws sheet version 系列命令\n\n流程:\n 1. 获取历史版本下载 URL 和签名请求头 (download_file_version)\n 2. HTTP GET 下载文件二进制内容到本地\n\n版本号从 dws drive list --node \u003cdentryUuid\u003e --versions 获取。",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "download_file_version",
"primary_cli_path": "drive download-version",
"reviewed": true,
"risk": "low",
"title": "下载文件历史版本到本地",
"use_when": [
"用户要下载文件的历史版本/旧版本",
"版本号已通过 drive list --versions 获取"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"设置文档封面用 doc style cover set"
],
"canonical_path": "drive.get_cover",
"cli_name": "cover",
"cli_path": "drive cover",
"confirmation": "not_required",
"description": "获取节点封面地址",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "get_cover",
"primary_cli_path": "drive cover",
"reviewed": true,
"risk": "low",
"title": "获取节点封面地址",
"use_when": [
"用户说 封面/封面图/缩略图/预览图"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取文件元数据信息",
@@ -16220,6 +16546,33 @@
"用户要查看节点阅读/编辑/评论/点赞/预览/下载等统计维度时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取当前用户的收藏列表,支持分页与按内容类型筛选",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"操作单个收藏用 mark_star/unmark_star"
],
"canonical_path": "drive.get_star_list",
"cli_name": "list",
"cli_path": "drive star list",
"confirmation": "not_required",
"description": "获取收藏列表",
"effect": "read",
"group": "star",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "get_star_list",
"primary_cli_path": "drive star list",
"reviewed": true,
"risk": "low",
"title": "获取收藏列表",
"use_when": [
"用户说 我的收藏/收藏列表/收藏了哪些文档"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取文件上传信息",
@@ -16345,6 +16698,33 @@
"兼容入口:枚举钉盘企业空间或「我的文件」空间以拿 spaceId/rootFolderId 时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "收藏文档/文件到当前用户收藏列表",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"取消收藏用 unmark_star;查看收藏列表用 get_star_list"
],
"canonical_path": "drive.mark_star",
"cli_name": "add",
"cli_path": "drive star add",
"confirmation": "not_required",
"description": "收藏文档",
"effect": "write",
"group": "star",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "mark_star",
"primary_cli_path": "drive star add",
"reviewed": true,
"risk": "low",
"title": "收藏文档",
"use_when": [
"用户说 收藏这个文档/加个收藏/标星"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
@@ -16528,6 +16908,33 @@
"用户明确要求关闭文件的互联网公开发布,使外部链接失效时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询节点可申请的权限角色列表与审批人列表",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"实际发起申请用 apply_permission"
],
"canonical_path": "drive.query_permission_apply_info",
"cli_name": "apply-info",
"cli_path": "drive permission apply-info",
"confirmation": "not_required",
"description": "查询节点可申请的角色与审批人",
"effect": "read",
"group": "permission",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "query_permission_apply_info",
"primary_cli_path": "drive permission apply-info",
"reviewed": true,
"risk": "low",
"title": "查询节点可申请的角色与审批人",
"use_when": [
"无权限访问文档时,先查可申请角色与审批人"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取当前用户最近访问或编辑过的文档列表",
@@ -16644,6 +17051,32 @@
"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"在线文档回滚用 doc version revert;在线表格用 sheet version revert"
],
"canonical_path": "drive.revert_file_version",
"cli_name": "revert",
"cli_path": "drive revert",
"confirmation": "user_required",
"description": "将指定文件回滚到某个历史版本。仅支持普通文件(Word、Excel、PDF、图片等)。\n在线文档请用 dws doc version revert,在线表格请用 dws sheet version revert。",
"effect": "write",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "revert_file_version",
"primary_cli_path": "drive revert",
"reviewed": true,
"risk": "medium",
"title": "[危险] 回滚文件到指定历史版本",
"use_when": [
"用户说 回滚版本/恢复到某个版本/版本回退,且目标是普通文件"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
@@ -16860,6 +17293,60 @@
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "转交文档或知识库所有者给指定用户(不可逆)",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"普通协作权限变更用 permission add/update/remove"
],
"canonical_path": "drive.transfer_owner",
"cli_name": "transfer-owner",
"cli_path": "drive permission transfer-owner",
"confirmation": "user_required",
"description": "转交文档或知识库的所有者给指定用户。此操作不可逆,执行前需要确认。\n\n--node 和 --workspace 二选一。转交后原所有者保留角色由 --reserve-role 指定。\n使用 --yes 跳过确认时,--reserve-role 和 --recursive 必须显式指定。",
"effect": "write",
"group": "permission",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "transfer_owner",
"primary_cli_path": "drive permission transfer-owner",
"reviewed": true,
"risk": "high",
"title": "[危险] 转交所有者",
"use_when": [
"用户明确要求转交文档/知识库所有权"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "将文档/文件从当前用户收藏列表移除",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"添加收藏用 mark_star"
],
"canonical_path": "drive.unmark_star",
"cli_name": "remove",
"cli_path": "drive star remove",
"confirmation": "not_required",
"description": "取消收藏文档",
"effect": "write",
"group": "star",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "unmark_star",
"primary_cli_path": "drive star remove",
"reviewed": true,
"risk": "low",
"title": "取消收藏文档",
"use_when": [
"用户说 取消收藏/去掉收藏/不收藏了"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
@@ -21728,7 +22215,7 @@
"id": "sheet",
"name": "钉钉表格管理",
"runtime": true,
"tool_count": 81,
"tool_count": 90,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -22165,6 +22652,33 @@
"已有 axls 文档,需要新增一个工作表页签时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "在指定单元格上创建评论,可 @ 用户",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"回复已有评论用 comment reply;不要把评论写进单元格值"
],
"canonical_path": "sheet.create_sheet_comment",
"cli_name": "create",
"cli_path": "sheet comment create",
"confirmation": "not_required",
"description": "创建单元格评论",
"effect": "write",
"group": "comment",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "create_sheet_comment",
"primary_cli_path": "sheet comment create",
"reviewed": true,
"risk": "low",
"title": "创建单元格评论",
"use_when": [
"用户说 给单元格加评论/批注/@某人讨论这个数据"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "创建钉钉在线电子表格文档(axls),返回 nodeId。",
@@ -22435,6 +22949,33 @@
"用户明确要求永久删除某个工作表页签,且已确认目标 sheetId 时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "删除单元格评论(不可恢复)",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"标记解决不等于删除"
],
"canonical_path": "sheet.delete_sheet_comment",
"cli_name": "delete",
"cli_path": "sheet comment delete",
"confirmation": "user_required",
"description": "删除单元格评论",
"effect": "write",
"group": "comment",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "delete_sheet_comment",
"primary_cli_path": "sheet comment delete",
"reviewed": true,
"risk": "medium",
"title": "删除单元格评论",
"use_when": [
"用户明确要求删除某条评论"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按源区域对目标区域做自动填充(复制或序列)。",
@@ -22687,6 +23228,32 @@
"要查找包含某文本或公式的单元格位置时,必须用服务端 find"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "扫描表格公式单元格并按错误类型聚合返回错误数量与位置",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"读取公式文本用 range read --value-render-option formula"
],
"canonical_path": "sheet.formula_verify",
"cli_name": "formula-verify",
"cli_path": "sheet formula-verify",
"confirmation": "not_required",
"description": "扫描钉钉电子表格中已落表的公式单元格,按计算结果错误类型聚合返回错误数量、位置和样本。\n\n不指定 --sheet-id / --range / --targets 时默认扫描整本表格的全部工作表。",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "formula_verify",
"primary_cli_path": "sheet formula-verify",
"reviewed": true,
"risk": "low",
"title": "校验表格公式错误",
"use_when": [
"用户说 校验公式/检查公式错误/公式错误扫描"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "列出电子表格文档内全部工作表的 ID 与名称。",
@@ -23125,6 +23692,33 @@
"需要查看工作表上有哪些透视表或某个透视表详情时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询表格单元格评论列表,支持分页与按解决状态过滤",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"新建评论用 comment create"
],
"canonical_path": "sheet.list_sheet_comments",
"cli_name": "list",
"cli_path": "sheet comment list",
"confirmation": "not_required",
"description": "查询表格评论列表",
"effect": "read",
"group": "comment",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "list_sheet_comments",
"primary_cli_path": "sheet comment list",
"reviewed": true,
"risk": "low",
"title": "查询表格评论列表",
"use_when": [
"用户说 看某格的评论/表格里有哪些批注"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "上传附件到表格并返回 resourceUrl(浮动图片前置步骤)。",
@@ -23451,6 +24045,33 @@
"要把工作表中匹配文本批量替换为另一文本时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "回复指定单元格评论,支持表情贴图回复",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"修改评论内容用 comment update"
],
"canonical_path": "sheet.reply_sheet_comment",
"cli_name": "reply",
"cli_path": "sheet comment reply",
"confirmation": "not_required",
"description": "回复单元格评论",
"effect": "write",
"group": "comment",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "reply_sheet_comment",
"primary_cli_path": "sheet comment reply",
"reviewed": true,
"risk": "low",
"title": "回复单元格评论",
"use_when": [
"用户说 回复这条评论"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "为指定范围设置下拉列表(可多选、可带颜色)。",
@@ -24089,6 +24710,114 @@
"要重命名工作表、调整页签顺序、隐藏/显示、冻结行列或改标签颜色时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "更新单元格评论内容",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"回复评论用 comment reply"
],
"canonical_path": "sheet.update_sheet_comment",
"cli_name": "update",
"cli_path": "sheet comment update",
"confirmation": "not_required",
"description": "更新单元格评论",
"effect": "write",
"group": "comment",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "update_sheet_comment",
"primary_cli_path": "sheet comment update",
"reviewed": true,
"risk": "low",
"title": "更新单元格评论",
"use_when": [
"用户说 改一下这条评论"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查看表格历史版本列表",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"回滚用 version revert"
],
"canonical_path": "sheet.version_list",
"cli_name": "list",
"cli_path": "sheet version list",
"confirmation": "not_required",
"description": "查看表格历史版本列表",
"effect": "read",
"group": "version",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "version_list",
"primary_cli_path": "sheet version list",
"reviewed": true,
"risk": "low",
"title": "查看表格历史版本列表",
"use_when": [
"用户说 看历史版本/版本列表,目标是在线表格"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "回滚表格到指定历史版本",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"普通文件回滚用 drive revert;在线文档用 doc version revert"
],
"canonical_path": "sheet.version_revert",
"cli_name": "revert",
"cli_path": "sheet version revert",
"confirmation": "user_required",
"description": "[危险] 回滚表格到指定版本",
"effect": "write",
"group": "version",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "version_revert",
"primary_cli_path": "sheet version revert",
"reviewed": true,
"risk": "medium",
"title": "[危险] 回滚表格到指定版本",
"use_when": [
"用户说 回滚到某个版本/恢复到之前的表格"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "手动保存表格版本快照",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"回滚用 version revert;查看历史用 version list"
],
"canonical_path": "sheet.version_save",
"cli_name": "save",
"cli_path": "sheet version save",
"confirmation": "not_required",
"description": "手动保存表格版本快照",
"effect": "write",
"group": "version",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "version_save",
"primary_cli_path": "sheet version save",
"reviewed": true,
"risk": "low",
"title": "手动保存表格版本快照",
"use_when": [
"用户说 保存版本/存个快照,目标是在线表格"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "上传图片并写入指定单元格(占单元格内容)。",
@@ -25785,6 +26514,6 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 813
"tool_count": 840
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,174 @@
{
"id": "chat",
"tools": [
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
},
{
"canonical_path": "chat.shortcut_bot_find",
"cli_path": "chat +bot-find"
},
{
"canonical_path": "chat.shortcut_bot_search",
"cli_path": "chat +bot-search"
},
{
"canonical_path": "chat.shortcut_broadcast",
"cli_path": "chat +broadcast"
},
{
"canonical_path": "chat.shortcut_category_create",
"cli_path": "chat +category-create"
},
{
"canonical_path": "chat.shortcut_category_delete",
"cli_path": "chat +category-delete"
},
{
"canonical_path": "chat.shortcut_category_list",
"cli_path": "chat +category-list"
},
{
"canonical_path": "chat.shortcut_category_rename",
"cli_path": "chat +category-rename"
},
{
"canonical_path": "chat.shortcut_chat_bots",
"cli_path": "chat +chat-bots"
},
{
"canonical_path": "chat.shortcut_chat_dismiss",
"cli_path": "chat +chat-dismiss"
},
{
"canonical_path": "chat.shortcut_chat_invite_url",
"cli_path": "chat +chat-invite-url"
},
{
"canonical_path": "chat.shortcut_chat_list_all",
"cli_path": "chat +chat-list-all"
},
{
"canonical_path": "chat.shortcut_chat_list_join_requests",
"cli_path": "chat +chat-list-join-requests"
},
{
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
},
{
"canonical_path": "chat.shortcut_chat_role_add",
"cli_path": "chat +chat-role-add"
},
{
"canonical_path": "chat.shortcut_chat_role_list",
"cli_path": "chat +chat-role-list"
},
{
"canonical_path": "chat.shortcut_chat_role_query_user",
"cli_path": "chat +chat-role-query-user"
},
{
"canonical_path": "chat.shortcut_chat_role_set_user",
"cli_path": "chat +chat-role-set-user"
},
{
"canonical_path": "chat.shortcut_chat_role_update",
"cli_path": "chat +chat-role-update"
},
{
"canonical_path": "chat.shortcut_chat_search",
"cli_path": "chat +chat-search"
},
{
"canonical_path": "chat.shortcut_chat_set_admin",
"cli_path": "chat +chat-set-admin"
},
{
"canonical_path": "chat.shortcut_chat_set_history",
"cli_path": "chat +chat-set-history"
},
{
"canonical_path": "chat.shortcut_chat_update_alias",
"cli_path": "chat +chat-update-alias"
},
{
"canonical_path": "chat.shortcut_chat_update_nick",
"cli_path": "chat +chat-update-nick"
},
{
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
"cli_path": "chat +conversation-clear-all-red-point"
},
{
"canonical_path": "chat.shortcut_conversation_info",
"cli_path": "chat +conversation-info"
},
{
"canonical_path": "chat.shortcut_conversation_list",
"cli_path": "chat +conversation-list"
},
{
"canonical_path": "chat.shortcut_conversation_list_top",
"cli_path": "chat +conversation-list-top"
},
{
"canonical_path": "chat.shortcut_dm",
"cli_path": "chat +dm"
},
{
"canonical_path": "chat.shortcut_group_members",
"cli_path": "chat +group-members"
},
{
"canonical_path": "chat.shortcut_messages_list_direct",
"cli_path": "chat +messages-list-direct"
},
{
"canonical_path": "chat.shortcut_messages_list_pin",
"cli_path": "chat +messages-list-pin"
},
{
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
"cli_path": "chat +messages-list-unread-conversations"
},
{
"canonical_path": "chat.shortcut_messages_mget",
"cli_path": "chat +messages-mget"
},
{
"canonical_path": "chat.shortcut_messages_query_send_status",
"cli_path": "chat +messages-query-send-status"
},
{
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
},
{
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
@@ -9,6 +177,10 @@
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
@@ -17,6 +189,10 @@
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
@@ -41,6 +217,10 @@
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
@@ -89,10 +269,26 @@
"canonical_path": "chat.update_group_icon",
"cli_path": "chat group update-icon"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
},
{
"canonical_path": "chat.update_group_settings",
"cli_path": "chat group update-settings"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
@@ -157,6 +353,10 @@
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
@@ -277,6 +477,10 @@
"canonical_path": "chat.update_streaming_card",
"cli_path": "chat message update-card"
},
{
"canonical_path": "chat.update_text_emotion",
"cli_path": "chat message update-text-emotion"
},
{
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
@@ -292,194 +496,6 @@
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.shortcut_bot_search",
"cli_path": "chat +bot-search"
},
{
"canonical_path": "chat.shortcut_bot_find",
"cli_path": "chat +bot-find"
},
{
"canonical_path": "chat.shortcut_conversation_info",
"cli_path": "chat +conversation-info"
},
{
"canonical_path": "chat.shortcut_conversation_clear_all_red_point",
"cli_path": "chat +conversation-clear-all-red-point"
},
{
"canonical_path": "chat.shortcut_conversation_list",
"cli_path": "chat +conversation-list"
},
{
"canonical_path": "chat.shortcut_conversation_list_top",
"cli_path": "chat +conversation-list-top"
},
{
"canonical_path": "chat.shortcut_category_list",
"cli_path": "chat +category-list"
},
{
"canonical_path": "chat.shortcut_category_create",
"cli_path": "chat +category-create"
},
{
"canonical_path": "chat.shortcut_category_delete",
"cli_path": "chat +category-delete"
},
{
"canonical_path": "chat.shortcut_category_rename",
"cli_path": "chat +category-rename"
},
{
"canonical_path": "chat.shortcut_chat_search",
"cli_path": "chat +chat-search"
},
{
"canonical_path": "chat.shortcut_chat_invite_url",
"cli_path": "chat +chat-invite-url"
},
{
"canonical_path": "chat.shortcut_chat_dismiss",
"cli_path": "chat +chat-dismiss"
},
{
"canonical_path": "chat.shortcut_chat_set_history",
"cli_path": "chat +chat-set-history"
},
{
"canonical_path": "chat.shortcut_chat_update_nick",
"cli_path": "chat +chat-update-nick"
},
{
"canonical_path": "chat.shortcut_chat_update_alias",
"cli_path": "chat +chat-update-alias"
},
{
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_list_all",
"cli_path": "chat +chat-list-all"
},
{
"canonical_path": "chat.shortcut_chat_list_join_requests",
"cli_path": "chat +chat-list-join-requests"
},
{
"canonical_path": "chat.shortcut_chat_bots",
"cli_path": "chat +chat-bots"
},
{
"canonical_path": "chat.shortcut_chat_set_admin",
"cli_path": "chat +chat-set-admin"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
},
{
"canonical_path": "chat.shortcut_chat_role_list",
"cli_path": "chat +chat-role-list"
},
{
"canonical_path": "chat.shortcut_chat_role_add",
"cli_path": "chat +chat-role-add"
},
{
"canonical_path": "chat.shortcut_chat_role_update",
"cli_path": "chat +chat-role-update"
},
{
"canonical_path": "chat.shortcut_chat_role_set_user",
"cli_path": "chat +chat-role-set-user"
},
{
"canonical_path": "chat.shortcut_chat_role_query_user",
"cli_path": "chat +chat-role-query-user"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_list_direct",
"cli_path": "chat +messages-list-direct"
},
{
"canonical_path": "chat.shortcut_messages_list_unread_conversations",
"cli_path": "chat +messages-list-unread-conversations"
},
{
"canonical_path": "chat.shortcut_messages_mget",
"cli_path": "chat +messages-mget"
},
{
"canonical_path": "chat.shortcut_messages_query_send_status",
"cli_path": "chat +messages-query-send-status"
},
{
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
},
{
"canonical_path": "chat.shortcut_messages_list_pin",
"cli_path": "chat +messages-list-pin"
},
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
},
{
"canonical_path": "chat.shortcut_broadcast",
"cli_path": "chat +broadcast"
},
{
"canonical_path": "chat.shortcut_dm",
"cli_path": "chat +dm"
},
{
"canonical_path": "chat.shortcut_group_members",
"cli_path": "chat +group-members"
},
{
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
}
]
}
@@ -1,6 +1,74 @@
{
"id": "doc",
"tools": [
{
"canonical_path": "doc.shortcut_comment_create",
"cli_path": "doc +comment-create"
},
{
"canonical_path": "doc.shortcut_comment_list",
"cli_path": "doc +comment-list"
},
{
"canonical_path": "doc.shortcut_comment_reply",
"cli_path": "doc +comment-reply"
},
{
"canonical_path": "doc.shortcut_copy",
"cli_path": "doc +copy"
},
{
"canonical_path": "doc.shortcut_doc_append",
"cli_path": "doc +doc-append"
},
{
"canonical_path": "doc.shortcut_export_get",
"cli_path": "doc +export-get"
},
{
"canonical_path": "doc.shortcut_export_submit",
"cli_path": "doc +export-submit"
},
{
"canonical_path": "doc.shortcut_find_doc",
"cli_path": "doc +find-doc"
},
{
"canonical_path": "doc.shortcut_list",
"cli_path": "doc +list"
},
{
"canonical_path": "doc.shortcut_move",
"cli_path": "doc +move"
},
{
"canonical_path": "doc.shortcut_search",
"cli_path": "doc +search"
},
{
"canonical_path": "doc.shortcut_share_doc",
"cli_path": "doc +share-doc"
},
{
"canonical_path": "doc.shortcut_template_list",
"cli_path": "doc +template-list"
},
{
"canonical_path": "doc.shortcut_template_search",
"cli_path": "doc +template-search"
},
{
"canonical_path": "doc.shortcut_version_list",
"cli_path": "doc +version-list"
},
{
"canonical_path": "doc.shortcut_version_revert",
"cli_path": "doc +version-revert"
},
{
"canonical_path": "doc.shortcut_version_save",
"cli_path": "doc +version-save"
},
{
"canonical_path": "doc.delete_document_block",
"cli_path": "doc block delete"
@@ -121,6 +189,26 @@
"canonical_path": "doc.search_documents",
"cli_path": "doc search"
},
{
"canonical_path": "doc.style_background_clear",
"cli_path": "doc style background clear"
},
{
"canonical_path": "doc.style_background_set",
"cli_path": "doc style background set"
},
{
"canonical_path": "doc.style_cover_clear",
"cli_path": "doc style cover clear"
},
{
"canonical_path": "doc.style_cover_set",
"cli_path": "doc style cover set"
},
{
"canonical_path": "doc.get_document_style",
"cli_path": "doc style get"
},
{
"canonical_path": "doc.template_apply",
"cli_path": "doc template apply"
@@ -152,74 +240,6 @@
{
"canonical_path": "doc.version_save",
"cli_path": "doc version save"
},
{
"canonical_path": "doc.shortcut_search",
"cli_path": "doc +search"
},
{
"canonical_path": "doc.shortcut_list",
"cli_path": "doc +list"
},
{
"canonical_path": "doc.shortcut_copy",
"cli_path": "doc +copy"
},
{
"canonical_path": "doc.shortcut_move",
"cli_path": "doc +move"
},
{
"canonical_path": "doc.shortcut_comment_list",
"cli_path": "doc +comment-list"
},
{
"canonical_path": "doc.shortcut_comment_create",
"cli_path": "doc +comment-create"
},
{
"canonical_path": "doc.shortcut_comment_reply",
"cli_path": "doc +comment-reply"
},
{
"canonical_path": "doc.shortcut_export_submit",
"cli_path": "doc +export-submit"
},
{
"canonical_path": "doc.shortcut_export_get",
"cli_path": "doc +export-get"
},
{
"canonical_path": "doc.shortcut_version_save",
"cli_path": "doc +version-save"
},
{
"canonical_path": "doc.shortcut_version_list",
"cli_path": "doc +version-list"
},
{
"canonical_path": "doc.shortcut_version_revert",
"cli_path": "doc +version-revert"
},
{
"canonical_path": "doc.shortcut_template_list",
"cli_path": "doc +template-list"
},
{
"canonical_path": "doc.shortcut_template_search",
"cli_path": "doc +template-search"
},
{
"canonical_path": "doc.shortcut_doc_append",
"cli_path": "doc +doc-append"
},
{
"canonical_path": "doc.shortcut_find_doc",
"cli_path": "doc +find-doc"
},
{
"canonical_path": "doc.shortcut_share_doc",
"cli_path": "doc +share-doc"
}
]
}
@@ -1,6 +1,34 @@
{
"id": "drive",
"tools": [
{
"canonical_path": "drive.shortcut_copy",
"cli_path": "drive +copy"
},
{
"canonical_path": "drive.shortcut_find_file",
"cli_path": "drive +find-file"
},
{
"canonical_path": "drive.shortcut_info",
"cli_path": "drive +info"
},
{
"canonical_path": "drive.shortcut_move",
"cli_path": "drive +move"
},
{
"canonical_path": "drive.shortcut_recent",
"cli_path": "drive +recent"
},
{
"canonical_path": "drive.shortcut_search",
"cli_path": "drive +search"
},
{
"canonical_path": "drive.shortcut_search_docs",
"cli_path": "drive +search-docs"
},
{
"canonical_path": "drive.commit_upload",
"cli_path": "drive commit"
@@ -9,6 +37,10 @@
"canonical_path": "drive.copy_document",
"cli_path": "drive copy"
},
{
"canonical_path": "drive.get_cover",
"cli_path": "drive cover"
},
{
"canonical_path": "drive.delete_document",
"cli_path": "drive delete"
@@ -17,6 +49,10 @@
"canonical_path": "drive.download_file",
"cli_path": "drive download"
},
{
"canonical_path": "drive.download_file_version",
"cli_path": "drive download-version"
},
{
"canonical_path": "drive.get_file_info",
"cli_path": "drive info"
@@ -41,6 +77,14 @@
"canonical_path": "drive.add_permission",
"cli_path": "drive permission add"
},
{
"canonical_path": "drive.apply_permission",
"cli_path": "drive permission apply"
},
{
"canonical_path": "drive.query_permission_apply_info",
"cli_path": "drive permission apply-info"
},
{
"canonical_path": "drive.list_permission",
"cli_path": "drive permission list"
@@ -49,6 +93,10 @@
"canonical_path": "drive.permission_remove",
"cli_path": "drive permission remove"
},
{
"canonical_path": "drive.transfer_owner",
"cli_path": "drive permission transfer-owner"
},
{
"canonical_path": "drive.permission_update",
"cli_path": "drive permission update"
@@ -81,6 +129,10 @@
"canonical_path": "drive.rename_document",
"cli_path": "drive rename"
},
{
"canonical_path": "drive.revert_file_version",
"cli_path": "drive revert"
},
{
"canonical_path": "drive.search_files",
"cli_path": "drive search"
@@ -89,6 +141,18 @@
"canonical_path": "drive.create_shortcut",
"cli_path": "drive shortcut"
},
{
"canonical_path": "drive.mark_star",
"cli_path": "drive star add"
},
{
"canonical_path": "drive.get_star_list",
"cli_path": "drive star list"
},
{
"canonical_path": "drive.unmark_star",
"cli_path": "drive star remove"
},
{
"canonical_path": "drive.get_node_stats",
"cli_path": "drive stats"
@@ -100,34 +164,6 @@
{
"canonical_path": "drive.get_upload_info",
"cli_path": "drive upload-info"
},
{
"canonical_path": "drive.shortcut_info",
"cli_path": "drive +info"
},
{
"canonical_path": "drive.shortcut_search",
"cli_path": "drive +search"
},
{
"canonical_path": "drive.shortcut_search_docs",
"cli_path": "drive +search-docs"
},
{
"canonical_path": "drive.shortcut_copy",
"cli_path": "drive +copy"
},
{
"canonical_path": "drive.shortcut_move",
"cli_path": "drive +move"
},
{
"canonical_path": "drive.shortcut_recent",
"cli_path": "drive +recent"
},
{
"canonical_path": "drive.shortcut_find_file",
"cli_path": "drive +find-file"
}
]
}
@@ -1,6 +1,14 @@
{
"id": "sheet",
"tools": [
{
"canonical_path": "sheet.shortcut_list_sheets",
"cli_path": "sheet +list-sheets"
},
{
"canonical_path": "sheet.shortcut_read",
"cli_path": "sheet +read"
},
{
"canonical_path": "sheet.add_dimension",
"cli_path": "sheet add-dimension"
@@ -29,6 +37,26 @@
"canonical_path": "sheet.chart_update",
"cli_path": "sheet chart update"
},
{
"canonical_path": "sheet.create_sheet_comment",
"cli_path": "sheet comment create"
},
{
"canonical_path": "sheet.delete_sheet_comment",
"cli_path": "sheet comment delete"
},
{
"canonical_path": "sheet.list_sheet_comments",
"cli_path": "sheet comment list"
},
{
"canonical_path": "sheet.reply_sheet_comment",
"cli_path": "sheet comment reply"
},
{
"canonical_path": "sheet.update_sheet_comment",
"cli_path": "sheet comment update"
},
{
"canonical_path": "sheet.create_cond_format",
"cli_path": "sheet cond-format create"
@@ -149,6 +177,10 @@
"canonical_path": "sheet.find_cells",
"cli_path": "sheet find"
},
{
"canonical_path": "sheet.formula_verify",
"cli_path": "sheet formula-verify"
},
{
"canonical_path": "sheet.get_dropdown_lists",
"cli_path": "sheet get-dropdown"
@@ -165,10 +197,6 @@
"canonical_path": "sheet.hide_gridline",
"cli_path": "sheet hide-gridline"
},
{
"canonical_path": "sheet.info",
"cli_path": "sheet info"
},
{
"canonical_path": "sheet.import",
"cli_path": "sheet import create"
@@ -177,6 +205,10 @@
"canonical_path": "sheet.import_get",
"cli_path": "sheet import get"
},
{
"canonical_path": "sheet.info",
"cli_path": "sheet info"
},
{
"canonical_path": "sheet.insert_dimension",
"cli_path": "sheet insert-dimension"
@@ -319,17 +351,21 @@
"canonical_path": "sheet.update_float_image",
"cli_path": "sheet update-float-image"
},
{
"canonical_path": "sheet.version_list",
"cli_path": "sheet version list"
},
{
"canonical_path": "sheet.version_revert",
"cli_path": "sheet version revert"
},
{
"canonical_path": "sheet.version_save",
"cli_path": "sheet version save"
},
{
"canonical_path": "sheet.write_image",
"cli_path": "sheet write-image"
},
{
"canonical_path": "sheet.shortcut_list_sheets",
"cli_path": "sheet +list-sheets"
},
{
"canonical_path": "sheet.shortcut_read",
"cli_path": "sheet +read"
}
]
}
@@ -40,6 +40,7 @@ func init() {
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
@@ -1346,6 +1346,101 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "chat +unread-chats",
"runtime_gate": "none"
},
"chat.update_text_emotion": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI calls im/update_text_emotion, which is proven by dws-wukong develop@30f13f02 but absent from the pinned MCP metadata snapshot; no pinned interface_ref can represent the command yet.",
"reviewed": true,
"parameters": {
"background-id": {
"property": "backgroundId",
"description": "新文字表情的背景 ID",
"required": true
},
"chat": {
"property": "openConversationId",
"required": false
},
"conversation-id": {
"property": "openConversationId",
"description": "会话 openConversationId;与 --group、--id、--chat 四选一",
"required": false
},
"emotion-id": {
"property": "emotionId",
"description": "新的文字表情 ID,可通过 create-text-emotion 获取",
"required": true
},
"emotion-name": {
"property": "emotionName",
"description": "新的文字表情名称",
"required": true
},
"group": {
"property": "openConversationId",
"required": false
},
"id": {
"property": "openConversationId",
"required": false
},
"msg-id": {
"property": "openMsgId",
"description": "需要原地更新文字表情的消息 openMsgId",
"required": true
},
"old-emotion-id": {
"property": "oldEmotionId",
"description": "消息上当前文字表情的 emotionId",
"required": true
},
"text": {
"property": "text",
"description": "新的文字表情内容",
"required": true
}
},
"review_reason": "Ported from dws-wukong commits d6831318 and bcf56ccf, merged at 30f13f02. The four conversation locators alias one property; the other six business parameters are required by Cobra before the single im/update_text_emotion call.",
"cli_path": "chat message update-text-emotion",
"runtime_gate": "none"
},
"chat.get_group_mute_config": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"chat.batch_query_group_chat_settings": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"chat.batch_update_group_chat_settings": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
}
}
}
@@ -587,6 +587,61 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "doc +share-doc",
"runtime_gate": "typed_yes"
},
"doc.style_cover_set": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_cover_clear": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_background_set": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.style_background_clear": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"doc.get_document_style": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
}
}
}
+141 -42
View File
@@ -17,6 +17,17 @@
"availability": "available",
"reviewed": true
},
"drive.apply_permission": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"drive.commit_upload": {
"interface_mode": "mcp",
"availability": "available",
@@ -64,6 +75,28 @@
"availability": "available",
"reviewed": true
},
"drive.download_file_version": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_cover": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_file_info": {
"interface_mode": "mcp",
"availability": "available",
@@ -75,6 +108,17 @@
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true
},
"drive.get_star_list": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.get_upload_info": {
"interface_mode": "mcp",
"availability": "available",
@@ -100,6 +144,17 @@
"availability": "available",
"reviewed": true
},
"drive.mark_star": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.move_document": {
"interface_ref": {
"product_id": "doc",
@@ -164,6 +219,17 @@
"reviewed": true,
"runtime_gate": "confirm_dangerous"
},
"drive.query_permission_apply_info": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"drive.recent": {
"effect": "read",
"risk": "low",
@@ -212,28 +278,46 @@
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"cli_path": "drive rename"
},
"drive.revert_file_version": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"drive.search_files": {
"interface_mode": "mcp",
"availability": "available",
"reviewed": true
},
"drive.upload": {
"drive.shortcut_copy": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"parameters": {
"node": {
"property": "nodeId",
"required": false
}
},
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
"cli_path": "drive upload",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +copy",
"runtime_gate": "typed_yes"
},
"drive.shortcut_find_file": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +find-file",
"runtime_gate": "none"
},
"drive.shortcut_info": {
@@ -249,6 +333,32 @@
"cli_path": "drive +info",
"runtime_gate": "none"
},
"drive.shortcut_move": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +move",
"runtime_gate": "typed_yes"
},
"drive.shortcut_recent": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +recent",
"runtime_gate": "none"
},
"drive.shortcut_search": {
"effect": "read",
"risk": "low",
@@ -275,56 +385,45 @@
"cli_path": "drive +search-docs",
"runtime_gate": "none"
},
"drive.shortcut_copy": {
"drive.transfer_owner": {
"effect": "write",
"risk": "medium",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +copy",
"runtime_gate": "typed_yes"
"runtime_gate": "confirm_delete"
},
"drive.shortcut_move": {
"drive.unmark_star": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +move",
"runtime_gate": "typed_yes"
},
"drive.shortcut_recent": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +recent",
"runtime_gate": "none"
},
"drive.shortcut_find_file": {
"effect": "read",
"risk": "low",
"drive.upload": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "drive +find-file",
"parameters": {
"node": {
"property": "nodeId",
"required": false
}
},
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
"cli_path": "drive upload",
"runtime_gate": "none"
}
}
@@ -734,6 +734,105 @@
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"cli_path": "sheet +read",
"runtime_gate": "none"
},
"sheet.version_save": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.version_list": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.version_revert": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"sheet.formula_verify": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.list_sheet_comments": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.create_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.reply_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.update_sheet_comment": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "none"
},
"sheet.delete_sheet_comment": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"runtime_gate": "confirm_delete"
}
}
}
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 813,
"source_tools": 840,
"matched_tools": 71
},
"tools": {
@@ -2397,6 +2397,87 @@
"cobra-help:dws chat +unread-chats",
"ShortcutRegistry:chat +unread-chats"
]
},
"chat.update_text_emotion": {
"agent_summary": "把消息上已有的文字表情原地替换为新的文字表情",
"use_when": [
"需要更新消息的状态文字或表情,并避免先移除再添加造成闪烁和两次网络调用时"
],
"avoid_when": [
"消息上还没有文字表情时使用 chat message add-text-emotion",
"只需清除文字表情时使用 chat message remove-text-emotion"
],
"examples": [
"dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMessageId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name \"处理中\" --text \"处理中 2 分钟\" --background-id im_bg_5"
],
"reviewed": true,
"review_reason": "依据 issue #85 的原地状态更新场景、Wukong 30f13f02 的真实 Cobra/MCP 映射和开源实现复核;选择语义不改变执行、安全或参数事实。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_text_emotion",
"cobra-help:dws chat message update-text-emotion",
"dws-wukong:d6831318",
"dws-wukong:bcf56ccf",
"dws-wukong:30f13f02",
"github:hugozhu/dingtalk-opencode-tag#85",
"live-dws-schema:im.update_text_emotion#FAILED_NOT_AUTHENTICATED"
]
},
"chat.get_group_mute_config": {
"agent_summary": "查询群用户禁言配置(禁言黑名单/全员禁言白名单)",
"use_when": [
"用户说 看下群里谁被禁言/禁言配置"
],
"avoid_when": [
"设置全员禁言用 chat group-mute;禁言个人用 chat group-mute-member"
],
"examples": [
"dws chat group get-mute-config --group <openConversationId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.get_group_mute_config",
"cobra-help:dws chat group get-mute-config",
"wukong-develop:wukong/products"
]
},
"chat.batch_query_group_chat_settings": {
"agent_summary": "批量查询当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"use_when": [
"用户说 看下这些群我的置顶和免打扰设置"
],
"avoid_when": [
"管理员级群功能开关用 chat group update-settings"
],
"examples": [
"dws chat group user-settings query --groups cid1,cid2 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.batch_query_group_chat_settings",
"cobra-help:dws chat group user-settings query",
"wukong-develop:wukong/products"
]
},
"chat.batch_update_group_chat_settings": {
"agent_summary": "批量更新当前用户自己的群会话设置(置顶/免打扰/群昵称/群备注)",
"use_when": [
"用户说 把这些群都设为免打扰/置顶"
],
"avoid_when": [
"单个群昵称优先 chat group update-nick"
],
"examples": [
"dws chat group user-settings set --items '[{\"openConversationId\":\"cid1\",\"top\":true,\"mute\":false}]' --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=chat.batch_update_group_chat_settings",
"cobra-help:dws chat group user-settings set",
"wukong-develop:wukong/products"
]
}
},
"products": {
@@ -1226,6 +1226,101 @@
"cobra-help:dws doc +share-doc",
"ShortcutRegistry:doc +share-doc"
]
},
"doc.style_cover_set": {
"agent_summary": "设置钉钉文档顶部封面图(外链或本地图片上传)",
"use_when": [
"用户说 给文档设置封面/换个封面图"
],
"avoid_when": [
"读取当前封面用 doc style get;移除封面用 cover clear"
],
"examples": [
"dws doc style cover set --node <DOC_ID> --image https://img.example.com/cover.png --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_cover_set",
"cobra-help:dws doc style cover set",
"wukong-develop:wukong/products"
]
},
"doc.style_cover_clear": {
"agent_summary": "移除钉钉文档封面",
"use_when": [
"用户说 去掉文档封面"
],
"avoid_when": [
"设置封面用 cover set"
],
"examples": [
"dws doc style cover clear --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_cover_clear",
"cobra-help:dws doc style cover clear",
"wukong-develop:wukong/products"
]
},
"doc.style_background_set": {
"agent_summary": "设置钉钉文档背景纯色(#RRGGBB)",
"use_when": [
"用户说 给文档设置背景色"
],
"avoid_when": [
"背景不支持图片;封面用 cover set"
],
"examples": [
"dws doc style background set --node <DOC_ID> --color \"#E8F2FE\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_background_set",
"cobra-help:dws doc style background set",
"wukong-develop:wukong/products"
]
},
"doc.style_background_clear": {
"agent_summary": "清除钉钉文档背景",
"use_when": [
"用户说 去掉文档背景色"
],
"avoid_when": [
"设置背景用 background set"
],
"examples": [
"dws doc style background clear --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.style_background_clear",
"cobra-help:dws doc style background clear",
"wukong-develop:wukong/products"
]
},
"doc.get_document_style": {
"agent_summary": "读取文档当前封面与背景配置(只读)",
"use_when": [
"用户说 看下文档现在的封面/背景"
],
"avoid_when": [
"修改用 cover set / background set"
],
"examples": [
"dws doc style get --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=doc.get_document_style",
"cobra-help:dws doc style get",
"wukong-develop:wukong/products"
]
}
},
"products": {
+259 -84
View File
@@ -33,6 +33,25 @@
"live-mcp:dws schema doc.add_permission"
]
},
"drive.apply_permission": {
"agent_summary": "向审批人发起文档权限申请(会真实通知审批人)",
"use_when": [
"用户确认要为无权限文档发起权限申请"
],
"avoid_when": [
"先用 apply-info 查可申请角色与审批人;未经用户确认不得自行提交"
],
"examples": [
"dws drive permission apply --node <DOC_ID> --role READER --users <审批人userId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.apply_permission",
"cobra-help:dws drive permission apply",
"wukong-develop:wukong/products"
]
},
"drive.commit_upload": {
"agent_summary": "提交文件上传",
"use_when": [
@@ -187,6 +206,47 @@
"live-mcp:dws schema drive.download_file"
]
},
"drive.download_file_version": {
"agent_summary": "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
"use_when": [
"用户要下载文件的历史版本/旧版本",
"版本号已通过 drive list --versions 获取"
],
"avoid_when": [
"下载最新版本用 drive download",
"在线文档(adoc)历史版本用 doc version 系列命令",
"在线表格(axls)历史版本用 sheet version 系列命令"
],
"examples": [
"dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop drive download --version 分支实现移植为独立命令;单一 download_file_version 远端调用,参数契约与帮助文档一致。",
"source_refs": [
"CommandRegistry:canonical_path=drive.download_file_version",
"cobra-help:dws drive download-version",
"wukong-develop:wukong/products/drive.go"
]
},
"drive.get_cover": {
"agent_summary": "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
"use_when": [
"用户说 封面/封面图/缩略图/预览图"
],
"avoid_when": [
"设置文档封面用 doc style cover set"
],
"examples": [
"dws drive cover --node <dentryUuid> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.get_cover",
"cobra-help:dws drive cover",
"wukong-develop:wukong/products"
]
},
"drive.get_file_info": {
"agent_summary": "获取文件元数据信息",
"use_when": [
@@ -235,6 +295,26 @@
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"drive.get_star_list": {
"agent_summary": "获取当前用户的收藏列表,支持分页与按内容类型筛选",
"use_when": [
"用户说 我的收藏/收藏列表/收藏了哪些文档"
],
"avoid_when": [
"操作单个收藏用 mark_star/unmark_star"
],
"examples": [
"dws drive star list --format json",
"dws drive star list --content-types doc,sheet --limit 10 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.get_star_list",
"cobra-help:dws drive star list",
"wukong-develop:wukong/products"
]
},
"drive.get_upload_info": {
"agent_summary": "获取文件上传信息",
"use_when": [
@@ -334,6 +414,25 @@
"live-mcp:dws schema drive.list_spaces"
]
},
"drive.mark_star": {
"agent_summary": "收藏文档/文件到当前用户收藏列表",
"use_when": [
"用户说 收藏这个文档/加个收藏/标星"
],
"avoid_when": [
"取消收藏用 unmark_star;查看收藏列表用 get_star_list"
],
"examples": [
"dws drive star add --node <nodeId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.mark_star",
"cobra-help:dws drive star add",
"wukong-develop:wukong/products"
]
},
"drive.move_document": {
"agent_summary": "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
"use_when": [
@@ -475,6 +574,25 @@
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"drive.query_permission_apply_info": {
"agent_summary": "查询节点可申请的权限角色列表与审批人列表",
"use_when": [
"无权限访问文档时,先查可申请角色与审批人"
],
"avoid_when": [
"实际发起申请用 apply_permission"
],
"examples": [
"dws drive permission apply-info --node <DOC_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.query_permission_apply_info",
"cobra-help:dws drive permission apply-info",
"wukong-develop:wukong/products"
]
},
"drive.recent": {
"agent_summary": "获取当前用户最近访问或编辑过的文档列表",
"use_when": [
@@ -568,6 +686,25 @@
"live-mcp:dws schema doc.rename_document"
]
},
"drive.revert_file_version": {
"agent_summary": "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
"use_when": [
"用户说 回滚版本/恢复到某个版本/版本回退,且目标是普通文件"
],
"avoid_when": [
"在线文档回滚用 doc version revert;在线表格用 sheet version revert"
],
"examples": [
"dws drive revert --node <dentryUuid> --version 3 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.revert_file_version",
"cobra-help:dws drive revert",
"wukong-develop:wukong/products"
]
},
"drive.search_files": {
"agent_summary": "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
"use_when": [
@@ -595,32 +732,43 @@
"live-mcp:dws schema drive.search_files"
]
},
"drive.upload": {
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
"drive.shortcut_copy": {
"agent_summary": "复制文件/文档到指定位置",
"use_when": [
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
],
"avoid_when": [
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
"要把文件作为文档正文附件插入改用 dws doc media insert",
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive upload --file ./report.pdf --format json",
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
],
"reviewed": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"CommandRegistry:canonical_path=drive.upload",
"cobra-help:dws drive upload",
"Skill:skills/mono/references/products/drive.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
"structured-hint:internal/cli/schema_hints/products/drive.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
"cobra-help:dws drive +copy",
"ShortcutRegistry:drive +copy"
]
},
"drive.shortcut_find_file": {
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
"use_when": [
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +find-file --query 季度汇报",
"dws drive +find-file --query 合同"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
"cobra-help:dws drive +find-file",
"ShortcutRegistry:drive +find-file"
]
},
"drive.shortcut_info": {
@@ -642,64 +790,6 @@
"ShortcutRegistry:drive +info"
]
},
"drive.shortcut_search": {
"agent_summary": "搜索钉盘文件",
"use_when": [
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search --query \"季度汇报\"",
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search",
"cobra-help:dws drive +search",
"ShortcutRegistry:drive +search"
]
},
"drive.shortcut_search_docs": {
"agent_summary": "搜索文档空间文档",
"use_when": [
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search-docs --query \"季度汇报\""
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
"cobra-help:dws drive +search-docs",
"ShortcutRegistry:drive +search-docs"
]
},
"drive.shortcut_copy": {
"agent_summary": "复制文件/文档到指定位置",
"use_when": [
"当你想保留原件、把某个文件/文档拷贝一份到指定文件夹或知识库时使用;输入源节点 node 及目标 folder/workspace,会实际生成一个副本,原文件位置不变。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +copy --node <nodeId> --folder <targetFolderId>"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_copy",
"cobra-help:dws drive +copy",
"ShortcutRegistry:drive +copy"
]
},
"drive.shortcut_move": {
"agent_summary": "移动文件/文档到指定位置",
"use_when": [
@@ -739,24 +829,109 @@
"ShortcutRegistry:drive +recent"
]
},
"drive.shortcut_find_file": {
"agent_summary": "按名称关键词搜索钉盘文件并投影关键字段(只读)",
"drive.shortcut_search": {
"agent_summary": "搜索钉盘文件",
"use_when": [
"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"
"当你只记得文件名或内容关键词、不知道它在哪个目录时用它全局检索钉盘文件;输入 query,可按文件类型、扩展名、创建者、创建/修改时间范围过滤,返回匹配文件及其 ID,便于再做下载或整理。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +find-file --query 季度汇报",
"dws drive +find-file --query 合同"
"dws drive +search --query \"季度汇报\"",
"dws drive +search --query \"合同\" --target file --extensions pdf,docx"
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_find_file",
"cobra-help:dws drive +find-file",
"ShortcutRegistry:drive +find-file"
"internal/cli/schema_command_registry.json#drive.shortcut_search",
"cobra-help:dws drive +search",
"ShortcutRegistry:drive +search"
]
},
"drive.shortcut_search_docs": {
"agent_summary": "搜索文档空间文档",
"use_when": [
"当你只记得文档标题或关键词、想在文档空间/知识库中检索在线文档(区别于 +search 检索钉盘文件)时使用;输入 query 关键词,返回匹配的文档及其节点信息。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"examples": [
"dws drive +search-docs --query \"季度汇报\""
],
"reviewed": true,
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"source_refs": [
"internal/cli/schema_command_registry.json#drive.shortcut_search_docs",
"cobra-help:dws drive +search-docs",
"ShortcutRegistry:drive +search-docs"
]
},
"drive.transfer_owner": {
"agent_summary": "转交文档或知识库所有者给指定用户(不可逆)",
"use_when": [
"用户明确要求转交文档/知识库所有权"
],
"avoid_when": [
"普通协作权限变更用 permission add/update/remove"
],
"examples": [
"dws drive permission transfer-owner --node <DOC_ID> --new-owner <userId> --reserve-role EDITOR --recursive=false --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.transfer_owner",
"cobra-help:dws drive permission transfer-owner",
"wukong-develop:wukong/products"
]
},
"drive.unmark_star": {
"agent_summary": "将文档/文件从当前用户收藏列表移除",
"use_when": [
"用户说 取消收藏/去掉收藏/不收藏了"
],
"avoid_when": [
"添加收藏用 mark_star"
],
"examples": [
"dws drive star remove --node <nodeId> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.unmark_star",
"cobra-help:dws drive star remove",
"wukong-develop:wukong/products"
]
},
"drive.upload": {
"agent_summary": "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
"use_when": [
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认"
],
"avoid_when": [
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
"要把文件作为文档正文附件插入改用 dws doc media insert",
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录"
],
"examples": [
"dws drive upload --file ./report.pdf --format json",
"dws drive upload --file ./README.md --node <dentryUuid> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=drive.upload",
"cobra-help:dws drive upload",
"Skill:skills/mono/references/products/drive.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.upload",
"structured-hint:internal/cli/schema_hints/products/drive.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
}
},
@@ -1629,6 +1629,177 @@
"cobra-help:dws sheet +read",
"ShortcutRegistry:sheet +read"
]
},
"sheet.version_save": {
"agent_summary": "手动保存表格版本快照",
"use_when": [
"用户说 保存版本/存个快照,目标是在线表格"
],
"avoid_when": [
"回滚用 version revert;查看历史用 version list"
],
"examples": [
"dws sheet version save --node <SHEET_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_save",
"cobra-help:dws sheet version save",
"wukong-develop:wukong/products"
]
},
"sheet.version_list": {
"agent_summary": "查看表格历史版本列表",
"use_when": [
"用户说 看历史版本/版本列表,目标是在线表格"
],
"avoid_when": [
"回滚用 version revert"
],
"examples": [
"dws sheet version list --node <SHEET_ID> --limit 10 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_list",
"cobra-help:dws sheet version list",
"wukong-develop:wukong/products"
]
},
"sheet.version_revert": {
"agent_summary": "回滚表格到指定历史版本",
"use_when": [
"用户说 回滚到某个版本/恢复到之前的表格"
],
"avoid_when": [
"普通文件回滚用 drive revert;在线文档用 doc version revert"
],
"examples": [
"dws sheet version revert --node <SHEET_ID> --version 3 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.version_revert",
"cobra-help:dws sheet version revert",
"wukong-develop:wukong/products"
]
},
"sheet.formula_verify": {
"agent_summary": "扫描表格公式单元格并按错误类型聚合返回错误数量与位置",
"use_when": [
"用户说 校验公式/检查公式错误/公式错误扫描"
],
"avoid_when": [
"读取公式文本用 range read --value-render-option formula"
],
"examples": [
"dws sheet formula-verify --node <NODE_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.formula_verify",
"cobra-help:dws sheet formula-verify",
"wukong-develop:wukong/products"
]
},
"sheet.list_sheet_comments": {
"agent_summary": "查询表格单元格评论列表,支持分页与按解决状态过滤",
"use_when": [
"用户说 看某格的评论/表格里有哪些批注"
],
"avoid_when": [
"新建评论用 comment create"
],
"examples": [
"dws sheet comment list --node <SHEET_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.list_sheet_comments",
"cobra-help:dws sheet comment list",
"wukong-develop:wukong/products"
]
},
"sheet.create_sheet_comment": {
"agent_summary": "在指定单元格上创建评论,可 @ 用户",
"use_when": [
"用户说 给单元格加评论/批注/@某人讨论这个数据"
],
"avoid_when": [
"回复已有评论用 comment reply;不要把评论写进单元格值"
],
"examples": [
"dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content \"这个数字有问题\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.create_sheet_comment",
"cobra-help:dws sheet comment create",
"wukong-develop:wukong/products"
]
},
"sheet.reply_sheet_comment": {
"agent_summary": "回复指定单元格评论,支持表情贴图回复",
"use_when": [
"用户说 回复这条评论"
],
"avoid_when": [
"修改评论内容用 comment update"
],
"examples": [
"dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已核实\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.reply_sheet_comment",
"cobra-help:dws sheet comment reply",
"wukong-develop:wukong/products"
]
},
"sheet.update_sheet_comment": {
"agent_summary": "更新单元格评论内容",
"use_when": [
"用户说 改一下这条评论"
],
"avoid_when": [
"回复评论用 comment reply"
],
"examples": [
"dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content \"已修正\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.update_sheet_comment",
"cobra-help:dws sheet comment update",
"wukong-develop:wukong/products"
]
},
"sheet.delete_sheet_comment": {
"agent_summary": "删除单元格评论(不可恢复)",
"use_when": [
"用户明确要求删除某条评论"
],
"avoid_when": [
"标记解决不等于删除"
],
"examples": [
"dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:对照悟空 develop 同名命令实现、Cobra Long/Example 与 Runtime 确认门禁移植;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=sheet.delete_sheet_comment",
"cobra-help:dws sheet comment delete",
"wukong-develop:wukong/products"
]
}
},
"products": {
+85 -4
View File
@@ -2,8 +2,8 @@
"version": 3,
"baseline": {
"manifest": "schema-parameter-bindings-v3",
"sha256": "sha256:f48d7b55b6ca9719c466475ee32d678eab0deaee1d79ac4e4790c7ee441ebe7b",
"reason": "Reviewed v3 baseline after exposing the pinned chat.search_messages messageType, onlyRobotMessages, and searchConvType properties through exact Cobra flags.",
"sha256": "sha256:ff0f76145dd27da5429434a348344c9cdf74c44eaf6f4ea5411d29d904a9066f",
"reason": "Reviewed v3 baseline after binding sheet.info --include to the include property while porting the wukong sheet info expansion flag.",
"reviewed": true
},
"removals": {
@@ -43,6 +43,10 @@
"reason": "The public pagination flag was normalized from --size to --limit.",
"replaced_by": "oa.list_user_visible_process --limit",
"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
}
},
"bindings": {
@@ -1108,7 +1112,8 @@
"node": "nodeId"
},
"sheet.info": {
"node": "nodeId"
"node": "nodeId",
"include": "include"
},
"sheet.insert_dimension": {
"node": "nodeId"
@@ -1590,7 +1595,10 @@
"attendance.vacation_update_type --when-can-leave": "Reviewed unpinned adapter: attendance.vacation_update_type has no singular pinned interface_ref; --when-can-leave is a CLI wrapper input and does not publish a direct interface property.",
"chat.add_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.add_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.add_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
@@ -1607,7 +1615,12 @@
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
"chat.send_personal_message --file-path": "local upload/preprocessing input",
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
"chat.send_personal_message --text": "serialized into the aggregate content payload",
"chat.send_personal_message --title": "serialized into the aggregate content payload",
@@ -1632,8 +1645,8 @@
"dev.disable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.disable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.enable_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.enable_dev_app_robot --unified-app-id": "Reviewed unpinned adapter: dev.enable_dev_app_robot has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --app-key": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --app-key is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_credentials --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_credentials has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_version_detail --unified-app-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --unified-app-id is a CLI wrapper input and does not publish a direct interface property.",
"dev.get_dev_app_version_detail --version-id": "Reviewed unpinned adapter: dev.get_dev_app_version_detail has no singular pinned interface_ref; --version-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -1728,6 +1741,7 @@
"doc.get_document_content --scope": "Runtime extension sends scope for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --start-block-id": "Runtime extension sends startBlockId for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --tags": "Runtime extension sends tags for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_style --node": "Reviewed unpinned adapter: doc.get_document_style has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --task-id": "Reviewed unpinned adapter: doc.import_get has no singular pinned interface_ref; --task-id is a CLI wrapper input and does not publish a direct interface property.",
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
@@ -1735,6 +1749,14 @@
"doc.insert_document_block --text": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --node": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_clear --node": "Reviewed unpinned adapter: doc.style_cover_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --file": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --file is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --image": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --image is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --node": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_cover_set --position": "Reviewed unpinned adapter: doc.style_cover_set has no singular pinned interface_ref; --position is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --folder": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --folder is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --name": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --name is a CLI wrapper input and does not publish a direct interface property.",
"doc.template_apply --template-id": "Reviewed unpinned adapter: doc.template_apply has no singular pinned interface_ref; --template-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -1763,7 +1785,22 @@
"doc.version_revert --node": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_revert --version": "Reviewed unpinned adapter: doc.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_save --node": "Reviewed unpinned adapter: doc.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --node": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --notify-mode": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --notify-mode is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file --output": "local output path",
"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.",
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --cursor": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --limit": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --order-by": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --order-by is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --resource-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --resource-types is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --sort": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --sort is a CLI wrapper input and does not publish a direct interface property.",
"drive.list_files --cursor": "composite route maps to nextToken for drive.list_files or pageToken for doc.list_nodes",
"drive.list_files --folder": "composite route maps to parentId for drive.list_files or folderId for doc.list_nodes",
"drive.list_files --limit": "composite route maps to maxResults for drive.list_files or pageSize for doc.list_nodes",
@@ -1772,10 +1809,12 @@
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --permission": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --permission is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_unset --node": "Reviewed unpinned adapter: drive.publish_unset has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.query_permission_apply_info --node": "Reviewed unpinned adapter: drive.query_permission_apply_info has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --creator-type": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --creator-type is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --cursor": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"drive.recent --file-types": "Reviewed unpinned adapter: drive.recent has no singular pinned interface_ref; --file-types is a CLI wrapper input and does not publish a direct interface property.",
@@ -1786,6 +1825,14 @@
"drive.recycle_list --limit": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"drive.recycle_list --space-id": "Reviewed unpinned adapter: drive.recycle_list has no singular pinned interface_ref; --space-id is a CLI wrapper input and does not publish a direct interface property.",
"drive.recycle_restore --id": "Reviewed unpinned adapter: drive.recycle_restore has no singular pinned interface_ref; --id is a CLI wrapper input and does not publish a direct interface property.",
"drive.revert_file_version --node": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.revert_file_version --version": "Reviewed unpinned adapter: drive.revert_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --new-owner": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --new-owner is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --node": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --recursive": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --recursive is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --reserve-role": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --reserve-role is a CLI wrapper input and does not publish a direct interface property.",
"drive.transfer_owner --workspace": "Reviewed unpinned adapter: drive.transfer_owner has no singular pinned interface_ref; --workspace is a CLI wrapper input and does not publish a direct interface property.",
"drive.unmark_star --node": "Reviewed unpinned adapter: drive.unmark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"mail.create_download_session --name": "local download filename",
"mail.create_download_session --output": "local output path",
"mail.create_draft --attachment": "reviewed local attachment preprocessing input",
@@ -1831,9 +1878,16 @@
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-sheet-id": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --content": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --mention": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_cond_format --yes": "local confirmation control",
"sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.delete_sheet_comment --node": "Reviewed unpinned adapter: sheet.delete_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_get_criteria --column": "local post-filter over get_filter_views response",
"sheet.filter_view_get_criteria --filter-view-id": "local post-filter over get_filter_views response",
"sheet.filter_view_info --filter-view-id": "local post-filter over get_filter_views response",
@@ -1843,6 +1897,13 @@
"sheet.filter_view_update_criteria --filter-view-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --filter-view-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_update_criteria --node": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.filter_view_update_criteria --sheet-id": "Reviewed unpinned adapter: sheet.filter_view_update_criteria has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --exit-on-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --exit-on-error is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --max-cells": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-cells is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --max-locations-per-error": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --max-locations-per-error is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --node": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --range": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --sheet-id": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.formula_verify --targets": "Reviewed unpinned adapter: sheet.formula_verify has no singular pinned interface_ref; --targets is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --group-state": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --group-state is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --node": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.group_dimension --range": "Reviewed unpinned adapter: sheet.group_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
@@ -1850,6 +1911,12 @@
"sheet.hide_gridline --sheet-id": "Reviewed unpinned adapter: sheet.hide_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_pivot_tables --pivot-table-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_pivot_tables --sheet-id": "Reviewed unpinned adapter: sheet.list_pivot_tables has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --cursor": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --limit": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --node": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --range": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --resolve-status": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --resolve-status is a CLI wrapper input and does not publish a direct interface property.",
"sheet.list_sheet_comments --sheet-id": "Reviewed unpinned adapter: sheet.list_sheet_comments has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
@@ -1857,6 +1924,11 @@
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.submit_export_job --output": "local destination consumed after export job completion",
"sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.",
@@ -1881,6 +1953,15 @@
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --content": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_sheet_comment --node": "Reviewed unpinned adapter: sheet.update_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --cursor": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --limit": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_list --node": "Reviewed unpinned adapter: sheet.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_revert --node": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_revert --version": "Reviewed unpinned adapter: sheet.version_revert has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"sheet.version_save --node": "Reviewed unpinned adapter: sheet.version_save has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
"sheet.write_image --mime-type": "local upload metadata",
"sheet.write_image --name": "local upload metadata",
+145 -4
View File
@@ -1582,8 +1582,23 @@ func newChatCommand() *cobra.Command {
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
dentryId, spaceId, fileName, fileType, filePath, fileSize)
}
case "location":
latitude, _ := cmd.Flags().GetString("latitude")
longitude, _ := cmd.Flags().GetString("longitude")
locationName, _ := cmd.Flags().GetString("location-name")
mapThumbnailUrl, _ := cmd.Flags().GetString("map-thumbnail-url")
if latitude == "" || longitude == "" || locationName == "" || mapThumbnailUrl == "" {
return fmt.Errorf("--latitude, --longitude, --location-name, --map-thumbnail-url are all required for msgType=location")
}
contentJSON = fmt.Sprintf(`{"locationName":"%s","longitude":"%s","latitude":"%s","mapThumbnailUrl":"%s"}`, locationName, longitude, latitude, mapThumbnailUrl)
case "profile":
contactID, _ := cmd.Flags().GetString("contact-id")
if contactID == "" {
return fmt.Errorf("--contact-id is required for msgType=profile")
}
contentJSON = fmt.Sprintf(`{"openDingTalkId":"%s"}`, contactID)
default:
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video)", msgType)
return fmt.Errorf("unsupported --msg-type: %s (supported: image, file, audio, video, location, profile)", msgType)
}
params := map[string]any{
@@ -2184,8 +2199,10 @@ func newChatCommand() *cobra.Command {
}
if cmd.Flags().Changed("only-robot") {
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot")
} else if cmd.Flags().Changed("only-robot-messages") {
toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot-messages")
}
if v, _ := cmd.Flags().GetString("conversation-type"); v != "" {
if v := flagOrFallback(cmd, "conversation-type", "search-conv-type"); v != "" {
toolArgs["searchConvType"] = v
}
@@ -2492,7 +2509,12 @@ func newChatCommand() *cobra.Command {
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("map-thumbnail-url", "", "位置消息地图缩略图 mediaId,形如 @mediaId(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("contact-id", "", "联系人名片 openDingTalkId(msgType=profile 时必填)")
chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
@@ -2686,7 +2708,11 @@ func newChatCommand() *cobra.Command {
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("group")
chatMessageSearchAdvancedCmd.Flags().String("message-type", "", "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)")
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot", false, "只搜索机器人消息(可选;显式传 false 时也会传给下层)")
chatMessageSearchAdvancedCmd.Flags().Bool("only-robot-messages", false, "--only-robot 的别名")
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("only-robot-messages")
chatMessageSearchAdvancedCmd.Flags().String("conversation-type", "", "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)")
chatMessageSearchAdvancedCmd.Flags().String("search-conv-type", "", "--conversation-type 的别名")
_ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("search-conv-type")
chatMessageSearchAdvancedCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选)")
chatMessageSearchAdvancedCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选)")
chatMessageSearchAdvancedCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\")")
@@ -3186,6 +3212,40 @@ func newChatCommand() *cobra.Command {
chatMessageRemoveTextEmotionCmd.Flags().String("text", "", "文字内容 (必填)")
chatMessageRemoveTextEmotionCmd.Flags().String("background-id", "", "背景 ID (必填)")
chatMessageUpdateTextEmotionCmd := &cobra.Command{
Use: "update-text-emotion",
Short: "更新消息的文字表情回应",
Example: ` dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name "赞" --text "nice" --background-id im_bg_5`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
}
return callMCPToolOnServer("im", "update_text_emotion", map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
"openMsgId": mustGetFlag(cmd, "msg-id"),
"oldEmotionId": mustGetFlag(cmd, "old-emotion-id"),
"emotionId": mustGetFlag(cmd, "emotion-id"),
"emotionName": mustGetFlag(cmd, "emotion-name"),
"text": mustGetFlag(cmd, "text"),
"backgroundId": mustGetFlag(cmd, "background-id"),
})
},
}
chatMessageUpdateTextEmotionCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
chatMessageUpdateTextEmotionCmd.Flags().String("group", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("id", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("chat", "", "--conversation-id 的别名")
chatMessageUpdateTextEmotionCmd.Flags().String("msg-id", "", "消息 openMsgId (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("old-emotion-id", "", "待更新的原表情 ID (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-id", "", "新表情 ID (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("emotion-name", "", "新表情名称 (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("text", "", "新文字内容 (必填)")
chatMessageUpdateTextEmotionCmd.Flags().String("background-id", "", "新背景 ID (必填)")
chatMessageUpdateTextEmotionCmd.MarkFlagsOneRequired("conversation-id", "group", "id", "chat")
for _, name := range []string{"msg-id", "old-emotion-id", "emotion-id", "emotion-name", "text", "background-id"} {
_ = chatMessageUpdateTextEmotionCmd.MarkFlagRequired(name)
}
// ── 创建文字表情(获取 emotionId)──────────────────────
chatMessageCreateTextEmotionCmd := &cobra.Command{
@@ -3415,7 +3475,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
chatMessageCmd.AddCommand(chatMessageListCmd, chatMessageSendCmd, chatMessageSendByBotCmd, chatMessageRecallByBotCmd, chatMessageSendByWebhookCmd, chatMessageListTopicRepliesCmd, chatMessageListAllCmd, chatMessageListBySenderCmd, chatMessageListMentionsCmd, chatMessageListFocusedCmd, chatMessageListUnreadConversationsCmd, chatMessageSearchCmd, chatMessageListByIdsCmd, chatMessageAddEmojiCmd, chatMessageRemoveEmojiCmd, chatMessageAddTextEmotionCmd, chatMessageRemoveTextEmotionCmd, chatMessageUpdateTextEmotionCmd, chatMessageCreateTextEmotionCmd, chatMessageSearchAdvancedCmd, chatMessageQuerySendStatusCmd, chatMessageRecallCmd, chatMessageEditCmd, chatMessageReadStatusCmd, chatMessageSendCardCmd, chatMessageUpdateCardCmd, chatMessageDownloadMediaCmd)
chatBotCmd.AddCommand(chatBotSearchCmd)
chatCategoryCmd.AddCommand(chatCategoryListCmd, chatCategoryConvsCmd, chatCategoryCreateCmd, chatCategoryDeleteCmd, chatCategoryRenameCmd, chatCategoryAddConvCmd, chatCategoryRemoveConvCmd, chatCategoryListByConvCmd, chatCategoryBatchInfoCmd)
chatGroupCmd.AddCommand(chatGroupInfoByIdCmd)
@@ -3732,6 +3792,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatSetTopCmd.MarkFlagRequired("conversation-id")
chatSetTopCmd.Flags().Bool("off", false, "取消置顶(不传则设置置顶)")
// ── group get-mute-config: 查询群用户禁言配置 ───────────────
chatGroupGetMuteConfigCmd := &cobra.Command{
Use: "get-mute-config",
Short: "查询群用户禁言配置",
Long: `查询指定群的用户禁言配置,包括单独禁言黑名单、全员禁言白名单及相关操作时间。
返回的是原始配置记录,不等同于当前被禁言成员列表;全员禁言开关也不在本命令的返回范围内。`,
Example: ` dws chat group get-mute-config --group <openConversationId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
return callMCPToolOnServer("im", "get_group_mute_config", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
})
},
}
chatGroupGetMuteConfigCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
chatGroupCmd.AddCommand(chatGroupGetMuteConfigCmd)
// ── group-mute: 全员禁言 ───────────────────────────────
chatGroupMuteCmd := &cobra.Command{
@@ -5310,6 +5389,68 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatTextCmd.AddCommand(chatTextTranslateCmd)
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
// ── chat group user-settings ──
chatGroupUserSettingsCmd := &cobra.Command{
Use: "user-settings",
Short: "批量查询或更新当前用户的群会话设置",
RunE: groupRunE,
}
chatGroupUserSettingsQueryCmd := &cobra.Command{
Use: "query",
Short: "批量查询当前用户的群会话设置",
Example: ` dws chat group user-settings query --groups cid1,cid2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "groups"); err != nil {
return err
}
convIds := parseCSVValues(mustGetFlag(cmd, "groups"))
if len(convIds) == 0 {
return fmt.Errorf("--groups must not be empty")
}
if len(convIds) > 100 {
return fmt.Errorf("--groups batch size %d exceeds limit 100", len(convIds))
}
return callMCPToolOnServer("im", "batch_query_group_chat_settings", map[string]any{
"openConversationIds": convIds,
})
},
}
chatGroupUserSettingsQueryCmd.Flags().String("groups", "", "群会话 openConversationId 列表,逗号分隔,最多 100 个 (必填)")
chatGroupUserSettingsSetCmd := &cobra.Command{
Use: "set",
Short: "批量更新当前用户的群会话设置",
Example: ` dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "items"); err != nil {
return err
}
itemsJSON := mustGetFlag(cmd, "items")
var items []map[string]any
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
return fmt.Errorf("--items JSON parse error: %w", err)
}
if len(items) == 0 {
return fmt.Errorf("--items must not be empty")
}
if len(items) > 100 {
return fmt.Errorf("--items batch size %d exceeds limit 100", len(items))
}
// 每项必须携带非空 openConversationId,缺失时 fail-fast,避免下发无效批量更新。
for i, item := range items {
cid, _ := item["openConversationId"].(string)
if strings.TrimSpace(cid) == "" {
return fmt.Errorf("--items[%d] 缺少非空 openConversationId", i)
}
}
return callMCPToolOnServer("im", "batch_update_group_chat_settings", map[string]any{
"items": items,
})
},
}
chatGroupUserSettingsSetCmd.Flags().String("items", "", `JSON 数组,每项 {"openConversationId":"cid","top":bool,"mute":bool,"groupNick":"...","groupAlias":"..."} (必填)`)
chatGroupUserSettingsCmd.AddCommand(chatGroupUserSettingsQueryCmd, chatGroupUserSettingsSetCmd)
chatGroupCmd.AddCommand(chatGroupUserSettingsCmd)
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
chatBotCmd.AddCommand(chatBotFindCmd)
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
@@ -0,0 +1,209 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func executeChatPortsCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "conversation-id flag",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--msg-id", "msg-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
"--background-id", "im_bg_5",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-1",
"openMsgId": "msg-1",
"oldEmotionId": "old-1",
"emotionId": "new-1",
"emotionName": "like",
"text": "nice",
"backgroundId": "im_bg_5",
},
},
},
{
name: "group alias for conversation-id",
args: []string{
"message", "update-text-emotion",
"--group", "conv-2",
"--msg-id", "msg-2",
"--old-emotion-id", "old-2",
"--emotion-id", "new-2",
"--emotion-name", "heart",
"--text", "great",
"--background-id", "im_bg_1",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-2",
"openMsgId": "msg-2",
"oldEmotionId": "old-2",
"emotionId": "new-2",
"emotionName": "heart",
"text": "great",
"backgroundId": "im_bg_1",
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err != nil {
t.Fatalf("chat %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
fullArgs := []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--msg-id", "msg-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
"--background-id", "im_bg_5",
}
dropFlag := func(name string) []string {
out := make([]string, 0, len(fullArgs))
for i := 0; i < len(fullArgs); i++ {
if fullArgs[i] == name {
i++ // skip the flag value too
continue
}
out = append(out, fullArgs[i])
}
return out
}
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing conversation-id and aliases",
args: dropFlag("--conversation-id"),
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
},
{
name: "missing old-emotion-id",
args: dropFlag("--old-emotion-id"),
wantErr: `required flag(s) "old-emotion-id" not set`,
},
{
name: "missing msg-id and background-id",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
"--old-emotion-id", "old-1",
"--emotion-id", "new-1",
"--emotion-name", "like",
"--text", "nice",
},
wantErr: `required flag(s) "background-id", "msg-id" not set`,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config", "--group", "conv-1")
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"muteBlackList":[],"muteWhiteList":[]}`},
}}
err := executeChatPortsCoverageCommand(t, caller,
"group", "get-mute-config", "--group", "conv-9")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
if caller.calls[0]["openConversationId"] != "conv-9" {
t.Fatalf("raw args = %#v", caller.calls[0])
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config")
if err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("err = %v, want message containing --group", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
}
@@ -0,0 +1,86 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionMapsReplacementTemplate(t *testing.T) {
for _, locator := range []string{"conversation-id", "group", "id", "chat"} {
t.Run(locator, func(t *testing.T) {
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
"message", "update-text-emotion",
"--"+locator, "cid",
"--msg-id", "mid",
"--old-emotion-id", "old-emotion",
"--emotion-id", "new-emotion",
"--emotion-name", "赞",
"--text", "nice",
"--background-id", "im_bg_5",
)
if err != nil {
t.Fatalf("update-text-emotion returned error: %v", err)
}
requireWukongWeeklySyncCall(t, caller, wukongWeeklySyncCall{
server: "im",
tool: "update_text_emotion",
args: map[string]any{
"openConversationId": "cid",
"openMsgId": "mid",
"oldEmotionId": "old-emotion",
"emotionId": "new-emotion",
"emotionName": "赞",
"text": "nice",
"backgroundId": "im_bg_5",
},
})
})
}
}
func TestCrossPlatformCoverageChatMessageUpdateTextEmotionRequiresEveryBusinessParameter(t *testing.T) {
required := []struct {
name string
args []string
}{
{name: "conversation-id", args: []string{"--group", "cid"}},
{name: "msg-id", args: []string{"--msg-id", "mid"}},
{name: "old-emotion-id", args: []string{"--old-emotion-id", "old-emotion"}},
{name: "emotion-id", args: []string{"--emotion-id", "new-emotion"}},
{name: "emotion-name", args: []string{"--emotion-name", "赞"}},
{name: "text", args: []string{"--text", "nice"}},
{name: "background-id", args: []string{"--background-id", "im_bg_5"}},
}
for omitted := range required {
t.Run("missing-"+required[omitted].name, func(t *testing.T) {
args := []string{"message", "update-text-emotion"}
for i, parameter := range required {
if i != omitted {
args = append(args, parameter.args...)
}
}
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, args...)
if err == nil || !strings.Contains(err.Error(), required[omitted].name) {
t.Fatalf("missing %s error = %v", required[omitted].name, err)
}
requireWukongWeeklySyncNoCalls(t, caller)
})
}
}
+162
View File
@@ -0,0 +1,162 @@
package helpers
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageChatGroupUserSettingsQueryValidation(t *testing.T) {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "query", "--groups", ","); err == nil {
t.Fatal("empty groups returned nil")
}
tooMany := make([]string, 101)
for i := range tooMany {
tooMany[i] = "cid"
}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "query", "--groups", strings.Join(tooMany, ",")); err == nil {
t.Fatal("101 groups returned nil")
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetValidation(t *testing.T) {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", "[]"); err == nil {
t.Fatal("empty items returned nil")
}
var sb strings.Builder
sb.WriteString("[")
for i := 0; i < 101; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"openConversationId":"cid"}`)
}
sb.WriteString("]")
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", sb.String()); err == nil {
t.Fatal("101 items returned nil")
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetItemValidation(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"top":true}]`)
if err == nil || !strings.Contains(err.Error(), "openConversationId") || !strings.Contains(err.Error(), "--items[0]") {
t.Fatalf("err = %v, want missing openConversationId error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want no MCP call on invalid item", caller.calls)
}
// 非首项缺失时错误需带正确下标;空白字符串同样视为缺失。
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1"},{"openConversationId":" "}]`)
if err == nil || !strings.Contains(err.Error(), "--items[1]") {
t.Fatalf("err = %v, want --items[1] error", err)
}
// 合法条目仍然成功下发。
caller = &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true,"mute":false}]`); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "batch_update_group_chat_settings" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageChatGroupUserSettingsSetHappyPath(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true}]`)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "im" || call.toolName != "batch_update_group_chat_settings" {
t.Fatalf("call = %#v", call)
}
}
func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location", "--latitude", "39.9")
if err == nil || !strings.Contains(err.Error(), "are all required for msgType=location") {
t.Fatalf("err = %v, want missing location params", err)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location",
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["msgType"] != "location" {
t.Fatalf("msgType = %#v", call.args["msgType"])
}
content, _ := call.args["content"].(string)
if !strings.Contains(content, `"latitude":"39.9"`) || !strings.Contains(content, `"mapThumbnailUrl":"@media1"`) {
t.Fatalf("content = %s", content)
}
}
func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile")
if err == nil || !strings.Contains(err.Error(), "--contact-id is required") {
t.Fatalf("err = %v, want contact-id required", err)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
content, _ := caller.calls[0].args["content"].(string)
if !strings.Contains(content, `"openDingTalkId":"od123"`) {
t.Fatalf("content = %s", content)
}
}
func TestCrossPlatformCoverageChatSearchAdvancedWukongAliases(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "search-advanced", "--query", "通知",
"--only-robot-messages", "--search-conv-type", "group_chat")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
if args["onlyRobotMessages"] != true {
t.Fatalf("onlyRobotMessages = %#v", args["onlyRobotMessages"])
}
if args["searchConvType"] != "group_chat" {
t.Fatalf("searchConvType = %#v", args["searchConvType"])
}
}
@@ -287,6 +287,14 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
path: "sheet range move-to",
args: []string{"range", "move-to", "--node", "node-1", "--sheet-id", "sheet-1", "--source-range", "A1:B3", "--target-range", "D1"},
},
{
path: "sheet comment delete",
args: []string{"comment", "delete", "--node", "node-1", "--comment-key", "ck-1"},
},
{
path: "sheet version revert",
args: []string{"version", "revert", "--node", "node-1", "--version", "2"},
},
}
// A newly protected command must add a runnable case here. This keeps the
+1 -1
View File
@@ -2901,7 +2901,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)
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())
return root
}
+321
View File
@@ -0,0 +1,321 @@
package helpers
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// doc_style.go — `dws doc style`(文档封面/背景样式配置)。
// 写入收口到 MCP 工具 update_document_style;读取用 get_document_style。
const docStyleToolName = "update_document_style"
const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
命令结构:
dws doc style cover set 设置文档封面
dws doc style cover clear 移除文档封面
dws doc style background set 设置文档背景纯色
dws doc style background clear 清除文档背景
dws doc style get 读取文档封面/背景 (只读)
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
coverCmd := &cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
Long: `为钉钉文档设置顶部封面图,可指定竖直显示位置。图片来源二选一:--image 外链或 --file 本地文件。`,
Example: ` dws doc style cover set --node DOC_ID --image https://img.example.com/cover.png
dws doc style cover set --node DOC_ID --file ./cover.png --position 0.3`,
RunE: runDocStyleCoverSet,
}
coverClearCmd := &cobra.Command{
Use: "clear",
Short: "移除文档封面",
Example: ` dws doc style cover clear --node DOC_ID`,
RunE: runDocStyleCoverClear,
}
backgroundCmd := &cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
Long: `为钉钉文档设置背景纯色(--color)。背景仅支持纯色,不支持背景图片上传(对齐前端能力)。`,
Example: ` dws doc style background set --node DOC_ID --color "#E8F2FE"`,
RunE: runDocStyleBackgroundSet,
}
backgroundClearCmd := &cobra.Command{
Use: "clear",
Short: "清除文档背景",
Example: ` dws doc style background clear --node DOC_ID`,
RunE: runDocStyleBackgroundClear,
}
getCmd := &cobra.Command{
Use: "get",
Short: "读取文档封面/背景 (只读)",
Long: `读取钉钉文档当前的封面与背景配置(只读,单接口收口 get_document_style)。`,
Example: ` dws doc style get --node DOC_ID`,
RunE: runDocStyleGet,
}
// cover set flags
coverSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
coverSetCmd.Flags().String("image", "", "封面图片 URL (外链会自动转存为内部地址)")
coverSetCmd.Flags().String("file", "", "本地图片文件路径 (与 --image 互斥)")
coverSetCmd.Flags().Float64("position", 0.5, "封面竖直位置 [0,1],默认 0.5")
coverSetCmd.MarkFlagsMutuallyExclusive("image", "file")
// cover clear flags
coverClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// background set flags(仅纯色,对齐前端;不支持背景图片上传)
backgroundSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
backgroundSetCmd.Flags().String("color", "", "背景纯色,如 #E8F2FE (必填)")
// background clear flags
backgroundClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// get flags
getCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// --node 隐藏别名 (--url/--id/--node-id/--doc-id/--file-id)
for _, c := range []*cobra.Command{coverSetCmd, coverClearCmd, backgroundSetCmd, backgroundClearCmd, getCmd} {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
c.Flags().String("node-id", "", "--node 的别名")
c.Flags().String("doc-id", "", "--node 的别名")
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
coverCmd.AddCommand(coverSetCmd, coverClearCmd)
backgroundCmd.AddCommand(backgroundSetCmd, backgroundClearCmd)
styleCmd.AddCommand(coverCmd, backgroundCmd, getCmd)
// 递归注册跨产品别名,让 cover set 的 --file 获得统一别名 --file-path。
registerCrossProductAliasesRecursive(styleCmd)
return styleCmd
}
// registerCrossProductAliasesRecursive 递归为 cmd 及其所有子命令注册跨产品别名。
// 可正确处理多层分组(如 style 的 cover/background 组下还有 set/clear 叶子)。
func registerCrossProductAliasesRecursive(cmd *cobra.Command) {
RegisterCrossProductAliases(cmd)
for _, sub := range cmd.Commands() {
registerCrossProductAliasesRecursive(sub)
}
}
// docStyleNodeID 解析目标文档标识,支持 --node 及隐藏别名。
func docStyleNodeID(cmd *cobra.Command) (string, error) {
return mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
}
func runDocStyleCoverSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 封面图片来源二选一:--image 外链(服务端转存公开)或 --file 本地上传(拿 resourceId 转存公开)。
image, _ := cmd.Flags().GetString("image")
filePath := flagOrFallback(cmd, "file", "file-path")
// --image 与 --file/--file-path 互斥。cobra 的 MarkFlagsMutuallyExclusive 只认主名
// image/file,拦不住别名 --file-path,故在此显式兜底。
if image != "" && filePath != "" {
return fmt.Errorf("--image and --file/--file-path are mutually exclusive; specify only one")
}
if image == "" && filePath == "" {
return fmt.Errorf("flag --image or --file is required for `cover set`")
}
cover := map[string]any{"action": "set"}
// position 客户端校验:帮助文档承诺 [0,1],越界直接报错,避免触发上传/请求。
if cmd.Flags().Changed("position") {
pos, _ := cmd.Flags().GetFloat64("position")
if pos < 0 || pos > 1 {
return fmt.Errorf("--position must be within [0,1], got %v", pos)
}
cover["position"] = pos
}
if image != "" {
cover["imageUrl"] = image
} else {
// --file:先做本地校验(存在 + 必须为图片);dry-run 时在上传前短路,
// 绝不读取/上传本地文件。
mimeType, fileSize, err := validateCoverImageFile(filePath)
if err != nil {
return err
}
if deps.Caller.DryRun() {
cover["resourceId"] = fmt.Sprintf("(pending upload from --file %s)", filePath)
} else {
// 使用命令上下文,让 Ctrl-C 能够取消上传凭证获取与 OSS 上传两次网络请求。
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
resourceID, err := uploadDocStyleImage(ctx, nodeID, filePath, filepath.Base(filePath), mimeType, fileSize)
if err != nil {
return err
}
cover["resourceId"] = resourceID
}
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": cover,
})
}
func runDocStyleCoverClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": map[string]any{"action": "clear"},
})
}
func runDocStyleBackgroundSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 背景仅支持纯色(对齐前端,不支持背景图片上传)。
color, _ := cmd.Flags().GetString("color")
if color == "" {
return fmt.Errorf("flag --color is required for `background set`")
}
// 帮助文档承诺 #RRGGBB 十六进制纯色,非法值直接报错,不下发请求。
if !isHexColor(color) {
return fmt.Errorf("--color must be a hex color like #E8F2FE, got %q", color)
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{
"action": "set",
"backgroundColor": color,
},
})
}
func runDocStyleBackgroundClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{"action": "clear"},
})
}
// runDocStyleGet 读取文档当前封面/背景(只读)。
func runDocStyleGet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleGetToolName, map[string]any{
"nodeId": nodeID,
})
}
// docStyleCoverMaxBytes 是 cover set --file 本地图片的大小上限(20 MiB)。
// 悟空参考实现未设上限,此处补充客户端 fail-fast,避免超大文件被先行上传。
const docStyleCoverMaxBytes = 20 << 20
// validateCoverImageFile 对 cover set --file 做本地校验(不上传/不读取文件内容):
// 文件须存在、非目录、不超过大小上限,且按扩展名推断的 MIME 必须为 image/*。
// 返回 mimeType 与文件大小,供 dry-run 预览与真实上传前 fail-fast,避免非法文件被先行上传。
func validateCoverImageFile(filePath string) (mimeType string, fileSize int64, err error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return "", 0, fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return "", 0, fmt.Errorf("%s is a directory, not a file", filePath)
}
if fileInfo.Size() > docStyleCoverMaxBytes {
return "", 0, fmt.Errorf("封面图片文件过大:上限 %d 字节 (20 MiB),实际 %d 字节 (%s)", int64(docStyleCoverMaxBytes), fileInfo.Size(), filepath.Base(filePath))
}
mimeType = inferMimeType(filepath.Base(filePath))
if !strings.HasPrefix(mimeType, "image/") {
return "", 0, fmt.Errorf("cover --file must be an image (png/jpg/jpeg/gif/svg/webp), got %s", filepath.Base(filePath))
}
return mimeType, fileInfo.Size(), nil
}
// isHexColor 校验 #RRGGBB 形式的十六进制颜色(大小写均可)。
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
for _, c := range s[1:] {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// uploadDocStyleImage 复用附件上传子流程(get_doc_attachment_upload_info + httpPutFile),
// 上传本地文件为鉴权资源,返回其 resourceId(供封面在服务端转存为公开读地址)。
// 入参 mimeType/fileSize 由调用方经 validateCoverImageFile 预校验得到;进度写 stderr,
// 保证 --format json 时 stdout 只含合法 JSON。本函数一定发起网络请求,调用方须自行做 dry-run 短路。
func uploadDocStyleImage(ctx context.Context, nodeID, filePath, fileName, mimeType string, fileSize int64) (string, error) {
deps.Out.PrintProgress(fmt.Sprintf("[1/2] 获取图片上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileSize),
"mimeType": mimeType,
})
if err != nil {
return "", err
}
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
if err != nil {
return "", err
}
deps.Out.PrintProgress("[2/2] 上传图片到 OSS...")
ossHeaders := map[string]string{"Content-Type": mimeType}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return "", err
}
return resourceID, nil
}
+230
View File
@@ -0,0 +1,230 @@
package helpers
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempImage(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte("fake-image-bytes"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestCrossPlatformCoverageValidateCoverImageFile(t *testing.T) {
if _, _, err := validateCoverImageFile(t.TempDir()); err == nil {
t.Fatal("directory returned nil")
}
path := writeTempImage(t, "cover.png")
mimeType, size, err := validateCoverImageFile(path)
if err != nil {
t.Fatal(err)
}
if mimeType != "image/png" || size != int64(len("fake-image-bytes")) {
t.Fatalf("mimeType=%q size=%d", mimeType, size)
}
}
func TestCrossPlatformCoverageIsHexColorEdge(t *testing.T) {
for _, valid := range []string{"#E8F2FE", "#e8f2fe", "#000000", "#aB1234"} {
if !isHexColor(valid) {
t.Fatalf("%s should be valid", valid)
}
}
for _, invalid := range []string{"#GG0000", "E8F2FE", "#E8F2F", "#E8F2FEE", ""} {
if isHexColor(invalid) {
t.Fatalf("%s should be invalid", invalid)
}
}
}
func executeDocStyleCommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
root := newDocStyleCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDocStyleCoverSetMutualExclusion(t *testing.T) {
err := executeDocStyleCommand(t, &scriptedToolCaller{},
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--file-path", "a.png")
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err = %v, want mutual exclusion", err)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetPositionValidation(t *testing.T) {
err := executeDocStyleCommand(t, &scriptedToolCaller{},
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "1.5")
if err == nil || !strings.Contains(err.Error(), "--position must be within [0,1]") {
t.Fatalf("err = %v, want position range error", err)
}
caller := &scriptedToolCaller{}
if err := executeDocStyleCommand(t, caller,
"cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "0.5"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetFileDryRun(t *testing.T) {
path := writeTempImage(t, "cover.png")
caller := &scriptedToolCaller{dry: true}
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
t.Fatal(err)
}
}
func stubHTTPPutFile(t *testing.T, err error) {
t.Helper()
old := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return err }
t.Cleanup(func() { httpPutFile = old })
}
func TestCrossPlatformCoverageDocStyleCoverSetFileUpload(t *testing.T) {
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetFileUploadFailure(t *testing.T) {
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, errors.New("oss down"))
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
}}
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
if err == nil || !strings.Contains(err.Error(), "oss down") {
t.Fatalf("err = %v, want oss failure", err)
}
}
func TestCrossPlatformCoverageUploadDocStyleImageFailures(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("cred failed")}}}
installScriptedCaller(t, caller)
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
t.Fatal("cred failure returned nil")
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `not json`}}}
installScriptedCaller(t, caller)
if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil {
t.Fatal("invalid upload info returned nil")
}
}
func TestCrossPlatformCoverageValidateCoverImageFileSizeLimit(t *testing.T) {
// 上限以内的正常图片通过校验。
path := writeTempImage(t, "cover.png")
if _, _, err := validateCoverImageFile(path); err != nil {
t.Fatal(err)
}
// os.Truncate 生成超过上限 1 字节的稀疏文件(瞬时创建,不占实际磁盘)。
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
t.Fatal(err)
}
_, _, err := validateCoverImageFile(path)
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") || !strings.Contains(err.Error(), "20 MiB") {
t.Fatalf("err = %v, want size limit error", err)
}
// 恰好等于上限时仍应通过。
if err := os.Truncate(path, docStyleCoverMaxBytes); err != nil {
t.Fatal(err)
}
if _, _, err := validateCoverImageFile(path); err != nil {
t.Fatalf("size == limit should pass, got %v", err)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetSizeLimitViaCommand(t *testing.T) {
path := writeTempImage(t, "cover.png")
if err := os.Truncate(path, docStyleCoverMaxBytes+1); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{}
err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path)
if err == nil || !strings.Contains(err.Error(), "封面图片文件过大") {
t.Fatalf("err = %v, want size limit error", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0 (fail before any network request)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetNilContextFallback(t *testing.T) {
// 直接调用 RunE(不经 Execute),cmd.Context() 为 nil,覆盖 context.Background() 兜底分支。
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
installScriptedCaller(t, caller)
root := newDocStyleCommand()
coverSet, _, err := root.Find([]string{"cover", "set"})
if err != nil {
t.Fatal(err)
}
if coverSet.Context() != nil {
t.Fatal("expected nil context on unexecuted command")
}
if err := coverSet.Flags().Set("node", "n1"); err != nil {
t.Fatal(err)
}
if err := coverSet.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
if err := runDocStyleCoverSet(coverSet, nil); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
func TestCrossPlatformCoverageDocStyleCoverSetUsesCommandContext(t *testing.T) {
// 经 ExecuteContext 执行时,上传流程应使用命令上下文;scriptedToolCaller 忽略 ctx,
// 故此处做行为性断言:正常上下文下上传路径成功走通(覆盖 ctx := cmd.Context() 分支)。
path := writeTempImage(t, "cover.png")
stubHTTPPutFile(t, nil)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`},
{text: `{}`},
}}
installScriptedCaller(t, caller)
root := newDocStyleCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"cover", "set", "--node", "n1", "--file", path})
if err := root.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls)
}
}
+424 -1
View File
@@ -302,9 +302,68 @@ func newDriveCommand() *cobra.Command {
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
if cmd.Flags().Changed("versions") {
if cmd.Flags().Changed("depth") && depth > 1 {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --depth 不能同时使用",
}
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --pattern 不能同时使用",
}
}
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPToolOnServer("drive", "list_file_versions", toolArgs)
}
if cmd.Flags().Changed("depth") {
if err := validateDriveListDepth(cmd, depth); err != nil {
return err
}
}
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
if rootFolder != "" {
if err := validateDocFolderID(rootFolder); err != nil {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--pattern 仅适用于钉盘文件列表,不能与 --workspace 同时使用",
}
}
toolArgs := map[string]any{"workspaceId": workspaceID}
if folder := docFolderFlag(cmd, "node", "file-id"); folder != "" {
if err := validateDocFolderID(folder); err != nil {
@@ -321,6 +380,30 @@ func newDriveCommand() *cobra.Command {
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
}
if depth > 1 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
baseArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("order"); v != "" {
baseArgs["order"] = 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 runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
}
// 默认路由:钉盘文件列表
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
@@ -451,6 +534,73 @@ func newDriveCommand() *cobra.Command {
},
}
driveDownloadVersionCmd := &cobra.Command{
Use: "download-version",
Short: "下载文件历史版本到本地",
Long: `下载钉盘文件的指定历史版本到本地(两步下载流程)。
仅适用于普通文件(如 pdf、docx、xlsx、png 等):
钉钉在线文档(adoc)请使用 dws doc version 系列命令
钉钉在线表格(axls)请使用 dws sheet version 系列命令
流程:
1. 获取历史版本下载 URL 和签名请求头 (download_file_version)
2. HTTP GET 下载文件二进制内容到本地
版本号从 dws drive list --node <dentryUuid> --versions 获取。`,
Example: ` dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf
dws drive download-version --node <dentryUuid> --version 3 --output ~/downloads/`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
versionNum, _ := cmd.Flags().GetInt("version")
if versionNum <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d(版本号从 drive list --versions 获取)", versionNum)
}
outputPath, _ := cmd.Flags().GetString("output")
if outputPath == "" {
return fmt.Errorf("flag --output is required")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
deps.Out.PrintKeyValue("输出", outputPath)
return nil
}
ctx := context.Background()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return err
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
filename := extractFileNameFromResponse(text)
if filename == "" {
filename = inferFilename(resourceURL)
}
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
return err
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
}
driveMkdirCmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
@@ -547,6 +697,11 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选,仅钉盘)")
driveListCmd.Flags().String("order", "", "排序方向: asc|desc,默认 desc (可选,仅钉盘)")
driveListCmd.Flags().Bool("thumbnail", false, "是否返回缩略图信息 (可选,仅钉盘)")
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
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 时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -555,6 +710,14 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
driveMkdirCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)")
@@ -1124,7 +1287,151 @@ func newDriveCommand() *cobra.Command {
_ = c.Flags().MarkHidden("file-id")
}
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd)
drivePermTransferOwnerCmd := &cobra.Command{
Use: "transfer-owner",
Short: "[危险] 转交所有者",
Long: `转交文档或知识库的所有者给指定用户。此操作不可逆,执行前需要确认。
--node 和 --workspace 二选一。转交后原所有者保留角色由 --reserve-role 指定。
使用 --yes 跳过确认时,--reserve-role 和 --recursive 必须显式指定。`,
Example: ` dws drive permission transfer-owner --node DOC_ID --new-owner uid123 --reserve-role EDITOR --recursive=false --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, _ := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if nodeID == "" && workspaceID == "" {
return fmt.Errorf("--node or --workspace is required")
}
// 不可逆高危操作:帮助文档承诺二选一,两者同传直接失败,绝不静默择一。
if nodeID != "" && workspaceID != "" {
return fmt.Errorf("--node and --workspace are mutually exclusive; specify exactly one")
}
if err := validateRequiredFlags(cmd, "new-owner"); err != nil {
return err
}
newOwnerID := mustGetFlag(cmd, "new-owner")
yesMode, _ := cmd.Flags().GetBool("yes")
if yesMode {
if !cmd.Flags().Changed("reserve-role") {
return fmt.Errorf("--reserve-role is required when using --yes")
}
if !cmd.Flags().Changed("recursive") {
return fmt.Errorf("--recursive is required when using --yes")
}
}
if commandDryRun(cmd) {
if deps.Caller.Format() == "json" {
payload := map[string]any{
"dry_run": true,
"executed": false,
"operation": "转交所有者",
"newOwnerId": newOwnerID,
}
if nodeID != "" {
payload["nodeId"] = nodeID
} else {
payload["workspaceId"] = workspaceID
}
return deps.Out.PrintJSON(payload)
}
deps.Out.PrintKeyValue("操作", "转交所有者")
deps.Out.PrintKeyValue("新所有者", newOwnerID)
return nil
}
reserveRole := mustGetFlag(cmd, "reserve-role")
recursive, _ := cmd.Flags().GetBool("recursive")
target := nodeID
if target == "" {
target = workspaceID
}
if !yesMode && !confirmDangerousAction(cmd, "转交所有者", target) {
return nil
}
toolArgs := map[string]any{"newOwnerId": newOwnerID}
if nodeID != "" {
toolArgs["nodeId"] = nodeID
} else {
toolArgs["workspaceId"] = workspaceID
}
if reserveRole != "" {
toolArgs["reserveOldOwnerRole"] = reserveRole
}
if cmd.Flags().Changed("recursive") {
toolArgs["recursiveChange"] = recursive
}
return callMCPToolOnServer("doc", "transfer_owner", toolArgs)
},
}
drivePermTransferOwnerCmd.Flags().String("node", "", "目标节点 ID 或 URL(与 --workspace 二选一)")
drivePermTransferOwnerCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(与 --node 二选一)")
drivePermTransferOwnerCmd.Flags().String("new-owner", "", "新所有者的用户 userId (必填)")
drivePermTransferOwnerCmd.Flags().String("reserve-role", "", "转交后原所有者保留角色: MANAGER / EDITOR / DOWNLOADER / READER / NONE")
drivePermTransferOwnerCmd.Flags().Bool("recursive", false, "是否递归变更所有子节点的所有者")
drivePermApplyInfoCmd := &cobra.Command{
Use: "apply-info",
Short: "查询节点可申请的角色与审批人",
Example: ` dws drive permission apply-info --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "query_permission_apply_info", map[string]any{"nodeId": nodeID})
},
}
drivePermApplyInfoCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd := &cobra.Command{
Use: "apply",
Short: "发起权限申请",
Long: `向指定节点的审批人发起权限申请。建议先用 apply-info 获取可申请角色与审批人。
注意: 本命令会真实通知审批人,Agent 必须先获得用户明确同意后再执行。`,
Example: ` dws drive permission apply --node DOC_ID --role READER --users uid1
dws drive permission apply --node DOC_ID --role EDITOR --users uid1,uid2 --reason "需要编辑该文档"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"receivers": userIds,
}
if v := mustGetFlag(cmd, "notify-mode"); v != "" {
toolArgs["notifyMode"] = v
}
if v := mustGetFlag(cmd, "reason"); v != "" {
toolArgs["reason"] = v
}
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "发起权限申请", fmt.Sprintf("节点 %s(将真实通知审批人 %s)", nodeID, strings.Join(userIds, ","))) {
return nil
}
return callMCPToolOnServer("drive", "apply_permission", toolArgs)
},
}
drivePermApplyCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd.Flags().String("role", "", "申请的角色: EDITOR / DOWNLOADER / READER (必填)")
drivePermApplyCmd.Flags().String("users", "", "审批人 userId 列表,逗号分隔 (必填)")
drivePermApplyCmd.Flags().String("user", "", "")
_ = drivePermApplyCmd.Flags().MarkHidden("user")
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
@@ -1397,11 +1704,124 @@ func newDriveCommand() *cobra.Command {
driveRecentCmd.Flags().String("page-token", "", "")
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := &cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
}
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
Example: ` dws drive star add --node <nodeId_or_URL>`,
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
}
return callMCPToolOnServer("drive", "mark_star", map[string]any{"nodeId": nodeID})
},
}
driveStarAddCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "取消收藏文档",
Example: ` dws drive star remove --node <nodeId_or_URL>`,
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
}
return callMCPToolOnServer("drive", "unmark_star", map[string]any{"nodeId": nodeID})
},
}
driveStarRemoveCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarListCmd := &cobra.Command{
Use: "list",
Short: "获取收藏列表",
Example: ` dws drive star list
dws drive star list --content-types doc,sheet --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["limit"] = v
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
toolArgs["cursor"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
toolArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("sort"); v != "" {
toolArgs["sortType"] = v
}
if v, _ := cmd.Flags().GetStringSlice("resource-types"); len(v) > 0 {
toolArgs["supportResourceTypes"] = v
}
if v, _ := cmd.Flags().GetStringSlice("content-types"); len(v) > 0 {
toolArgs["contentTypes"] = v
}
return callMCPToolOnServer("drive", "get_star_list", toolArgs)
},
}
driveStarListCmd.Flags().Int("limit", 0, "每页条数 (默认 20,最大 20)")
driveStarListCmd.Flags().String("cursor", "", "分页游标")
driveStarListCmd.Flags().String("order-by", "", "排序字段: createTime")
driveStarListCmd.Flags().String("sort", "", "排序方向: asc|desc")
driveStarListCmd.Flags().StringSlice("resource-types", nil, "资源大类: DENTRY, TEAM, WORKSPACE")
driveStarListCmd.Flags().StringSlice("content-types", nil, "内容类型: doc,sheet,ppt,whiteboard,mind,notable,pdf,other,folder")
driveStarCmd.AddCommand(driveStarAddCmd, driveStarRemoveCmd, driveStarListCmd)
// ── drive cover (获取节点封面地址) ──
driveCoverCmd := &cobra.Command{
Use: "cover",
Short: "获取节点封面地址",
Example: " dws drive cover --node <dentryUuid>",
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
}
return callMCPToolOnServer("drive", "get_cover", map[string]any{"nodeId": nodeID})
},
}
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚文件到指定历史版本",
Long: `将指定文件回滚到某个历史版本。仅支持普通文件(Word、Excel、PDF、图片等)。
在线文档请用 dws doc version revert,在线表格请用 dws sheet version revert。`,
Example: ` dws drive revert --node <dentryUuid> --version 3 --yes`,
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
}
versionNum, err := cmd.Flags().GetInt("version")
if err != nil || versionNum <= 0 {
return fmt.Errorf("flag --version is required and must be a positive integer")
}
if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "回滚文件版本", fmt.Sprintf("节点 %s 回滚到版本 %d", nodeID, versionNum)) {
return nil
}
return callMCPToolOnServer("drive", "revert_file_version", map[string]any{
"nodeId": nodeID,
"version": versionNum,
})
},
}
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
driveCmd.AddCommand(
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
driveUploadInfoCmd,
driveCommitCmd,
@@ -1418,6 +1838,9 @@ func newDriveCommand() *cobra.Command {
drivePermissionCmd,
drivePublishCmd,
recycleCmd,
driveStarCmd,
driveCoverCmd,
driveRevertCmd,
// deprecated 兼容命令(Phase 2)— 隐藏,保留向后兼容
driveFolderCmd,
)
+562
View File
@@ -0,0 +1,562 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// drive list --depth 递归编排(BFS + 限流补偿)
//
// 服务端无递归 API,CLI 侧 BFS 过渡方案;服务端递归 API 上线后本块整体退役。
// 钉盘(list_files)与知识库(list_nodes)共用一份路由无关骨架,差异收敛在 driveDepthRoute。
// ──────────────────────────────────────────────────────────
const (
// 50 来源于服务端 MAX_PAGE_SIZE 硬校验(超限抛错不 clamp),服务端放宽后仅需修改此常量。
driveDepthPageSize = 50
// 来源与 driveDepthPageSize 不同(list_nodes pageSize 上限),数值碰巧相同但不合并。
docDepthPageSize = 50
// API 调用数随目录宽度指数增长,超过报错不 clamp。
driveDepthMax = 5
driveDepthMaxItems = 2000
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
)
type driveDepthFolder struct {
id string // 钉盘 dentryUuid / 知识库 nodeId
name string
depth int
relPath string
retried bool // 已因 rate_limited 重新入队过一次,不二次补偿
// 限流重入队时保留失败页游标:已成功页的条目已进 collected,
// 从头重扫会产生重复条目并提前耗尽全局 2000 上限。
pageToken string
}
type driveDepthError struct {
Depth int `json:"depth"`
FolderID string `json:"folderId"`
FolderName string `json:"folderName"`
Reason string `json:"reason"`
Message string `json:"message"`
}
// 取消不是错误:不记 errors[]、不复用 1~6 退出码,固定 130(128+SIGINT)。
type driveDepthCancelledError struct{}
func (e *driveDepthCancelledError) Error() string { return driveDepthCancelledMsg }
func (e *driveDepthCancelledError) RawStderr() string { return driveDepthCancelledMsg }
func (e *driveDepthCancelledError) ExitCode() int { return 130 }
// depth==1 走原有单层路径,仅做范围校验,不触发互斥检查。
func validateDriveListDepth(cmd *cobra.Command, depth int) error {
if depth < 1 {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--depth 必须为 1~%d 的整数,当前: %d", driveDepthMax, depth),
}
}
if depth > driveDepthMax {
// 不 clamp:静默 clamp 会让用户误以为拿到了完整树。
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("DEPTH_EXCEED_MAX: --depth 最大为 %d,当前: %d(API 调用数随目录宽度指数增长,不做静默 clamp)", driveDepthMax, depth),
}
}
if depth == 1 {
return nil
}
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
}
return nil
}
func driveDepthExclusiveError(flag, reason string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--depth 不能与 --%s 同时使用:%s", flag, reason),
}
}
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
type driveDepthRoute struct {
serverID string // ""=钉盘自动路由 / "doc"=知识库
toolName string
pageSize int
// 钉盘契约无 hasMore 字段,只信空 nextToken;知识库契约含 hasMore,显式 false 即停。
// 钉盘的 hasMore 仅用于「hasMore=true 但 token 为空」异常检测。
hasMoreAuthoritative bool
buildArgs func(base map[string]any, folderID, pageToken string) map[string]any
parsePage func(text string) (items []map[string]any, nextToken string, hasMore bool)
isFolder func(item map[string]any) bool
itemID func(item map[string]any) string
}
func (r driveDepthRoute) fetchPage(ctx context.Context, args map[string]any) (string, error) {
if r.serverID == "" {
return callMCPToolReturnText(ctx, r.toolName, args)
}
return callMCPToolReturnTextOnServer(ctx, r.serverID, r.toolName, args)
}
func newDrivePanDepthRoute() driveDepthRoute {
return driveDepthRoute{
serverID: "",
toolName: "list_files",
pageSize: driveDepthPageSize,
hasMoreAuthoritative: false,
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
args := map[string]any{"maxResults": float64(driveDepthPageSize)}
for k, v := range base {
args[k] = v
}
if folderID != "" {
args["parentId"] = folderID
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
},
parsePage: parseDriveDepthPage,
isFolder: isDriveDepthFolder,
itemID: func(item map[string]any) string {
id, _ := item["fileId"].(string)
return id
},
}
}
// folderId 为空 = 知识库根。
func newDocDepthRoute() driveDepthRoute {
return driveDepthRoute{
serverID: "doc",
toolName: "list_nodes",
pageSize: docDepthPageSize,
// 字段缺失(契约违背)时按停处理——保守方向,宁可少翻页不空转。
hasMoreAuthoritative: true,
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
args := map[string]any{"pageSize": float64(docDepthPageSize)}
for k, v := range base {
args[k] = v
}
if folderID != "" {
args["folderId"] = folderID
}
if pageToken != "" {
args["pageToken"] = pageToken
}
return args
},
parsePage: parseDocDepthPage,
isFolder: isDocDepthFolder,
itemID: func(item map[string]any) string {
id, _ := item["nodeId"].(string)
return id
},
}
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
parent := cmd.Context()
if parent == nil {
parent = context.Background()
}
ctx, stop := signal.NotifyContext(parent, os.Interrupt)
defer stop()
// 兜底服务端「空页 + 非空游标」类 bug:该形态下条目不增长、全局 2000 永不触发。
// 不用「token 不推进即停」:兜不住每页 token 都不同的空页流。
maxPagesPerFolder := driveDepthMaxItems/route.pageSize + 1
traceID := fmt.Sprintf("drive-depth-%d", time.Now().UnixNano())
var (
collected []map[string]any
errs = make([]driveDepthError, 0)
truncated bool
visited = map[string]bool{}
)
queue := []driveDepthFolder{{id: rootFolderID, depth: 0}}
if rootFolderID != "" {
visited[rootFolderID] = true
}
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
folder := queue[0]
queue = queue[1:]
start := time.Now()
var folderErr error
pageToken := folder.pageToken
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
pages++
if pages > maxPagesPerFolder {
folderErr = &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("pagination anomaly: folder exceeded %d pages with non-empty nextToken, cursor loop suspected, directory may be truncated", maxPagesPerFolder),
}
break
}
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
}
if err != nil {
folderErr = err
break
}
items, next, hasMore := route.parsePage(text)
for _, item := range items {
name, _ := item["name"].(string)
rel := name
if folder.relPath != "" {
rel = folder.relPath + "/" + name
}
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
truncated = true
break
}
if route.isFolder(item) && folder.depth+1 < maxDepth {
if id := route.itemID(item); id != "" && !visited[id] {
// 重复条目静默丢弃
visited[id] = true
queue = append(queue, driveDepthFolder{id: id, name: name, depth: folder.depth + 1, relPath: rel})
}
}
}
if truncated {
break bfs
}
if hasMore && next == "" {
// 不静默截断——显式落 api_error,让消费方感知数据不完整。
folderErr = &CLIError{
Code: CodeMCPToolError,
Message: "pagination anomaly: hasMore=true but nextToken is empty, directory may be truncated",
}
break
}
if next == "" {
break
}
if route.hasMoreAuthoritative && !hasMore {
break
}
pageToken = next
}
slog.Info("drive list depth folder done",
"traceId", traceID, "depth", folder.depth, "folderId", folder.id,
"latency", time.Since(start).Milliseconds(), "failed", folderErr != nil)
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
return emitErr
}
return folderErr
}
if driveDepthErrorCode(folderErr) == driveDepthRateLimitedCode && !folder.retried {
// 限流命中在入口处、失败页本身无副作用;但此前成功页已进 collected,
// 必须从失败页游标续扫而非整目录重扫。不加 sleep/退避。
folder.retried = true
folder.pageToken = pageToken
queue = append(queue, folder)
continue
}
if folder.depth == 0 && len(collected) == 0 {
return folderErr
}
// 403 / transport 重试耗尽 / 限流重入队仍失败:记 errors[] 跳过
errs = append(errs, newDriveDepthError(folder, folderErr))
continue
}
if !quiet {
display := folder.name
if display == "" {
display = folder.id
}
if display == "" {
display = "<root>"
}
fmt.Fprintf(os.Stderr, "[drive-list] depth=%d folder=%s done\n", folder.depth, display)
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if matchDriveNamePattern(name, pattern) {
filtered = append(filtered, item)
}
}
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])
})
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if items == nil {
items = []map[string]any{}
}
return deps.Out.PrintJSON(map[string]any{
"items": items,
"maxDepth": maxDepth,
"truncated": truncated,
"errors": errs,
})
}
// 不输出预估调用次数:零调用下平均子文件夹数不可知,硬编码上界会被误读为真实估算。
func printDriveDepthDryRun(route driveDepthRoute, baseArgs map[string]any, maxDepth int) error {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": route.toolName,
"baseArgs": baseArgs,
"maxDepth": maxDepth,
"pageSize": route.pageSize,
"totalLimit": driveDepthMaxItems,
"warning": "调用数随目录宽度指数增长",
})
}
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
deps.Out.PrintKeyValue("Tool", route.toolName)
argsJSON, _ := json.MarshalIndent(baseArgs, " ", " ")
deps.Out.PrintKeyValue("baseArgs", "\n "+string(argsJSON))
deps.Out.PrintKeyValue("maxDepth", fmt.Sprintf("%d", maxDepth))
deps.Out.PrintKeyValue("每页条数", fmt.Sprintf("%d", route.pageSize))
deps.Out.PrintKeyValue("全局上限", fmt.Sprintf("%d", driveDepthMaxItems))
deps.Out.PrintWarning("调用数随目录宽度指数增长")
return nil
}
// 钉盘契约无 hasMore 字段,hasMoreExplicit 仅供「hasMore=true 但 token 为空」异常检测。
func parseDriveDepthPage(text string) (items []map[string]any, nextToken string, hasMoreExplicit bool) {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil, "", false
}
target := body
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
target = inner
}
key := driveDepthListItemsKey(target)
if key == "" {
return nil, "", false
}
arr, _ := target[key].([]any)
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
items = append(items, m)
}
}
nextToken, _ = target["nextToken"].(string)
hasMoreExplicit, _ = target["hasMore"].(bool)
return items, nextToken, hasMoreExplicit
}
func driveDepthListItemsKey(m map[string]any) string {
for _, k := range []string{"items", "dentryList"} {
if arr, ok := m[k].([]any); ok && arr != nil {
return k
}
}
return ""
}
// 非法通配符降级为子串匹配,避免 filepath.Match 报错导致过滤静默失效。
func matchDriveNamePattern(name, pattern string) bool {
if pattern == "" {
return true
}
p := pattern
if !strings.ContainsAny(p, "*?[") {
p = "*" + p + "*"
}
ok, err := filepath.Match(p, name)
if err != nil {
return strings.Contains(name, pattern)
}
return ok
}
func isDriveDepthFolder(item map[string]any) bool {
for _, key := range []string{"type", "dentryType"} {
if v, ok := item[key].(string); ok && strings.EqualFold(v, "FOLDER") {
return true
}
}
return false
}
// list_nodes 游标字段叫 nextPageToken,与钉盘的 nextToken 不同名。
func parseDocDepthPage(text string) (items []map[string]any, nextToken string, hasMore bool) {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil, "", false
}
arr, _ := body["nodes"].([]any)
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
items = append(items, m)
}
}
nextToken, _ = body["nextPageToken"].(string)
hasMore, _ = body["hasMore"].(bool)
return items, nextToken, hasMore
}
func isDocDepthFolder(item map[string]any) bool {
v, _ := item["nodeType"].(string)
return strings.EqualFold(v, "folder")
}
func driveDepthItemID(item map[string]any) string {
for _, key := range []string{"fileId", "nodeId"} {
if s, ok := item[key].(string); ok && s != "" {
return s
}
}
return ""
}
func driveDepthUnrecoverable(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
switch cliErr.Code {
case CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable:
return true
}
}
return false
}
// CLIError.Message 保留了原始响应 JSON。
func driveDepthErrorCode(err error) string {
var cliErr *CLIError
if !errors.As(err, &cliErr) {
return ""
}
var body map[string]any
if json.Unmarshal([]byte(cliErr.Message), &body) != nil {
return ""
}
for _, key := range []string{"errorCode", "error_code", "code"} {
if s, ok := body[key].(string); ok && s != "" {
return s
}
}
return ""
}
// 匹配顺序是硬约束:invalidRequest.rateLimited 自带 "invalidRequest." 前缀,
// 若先做 invalidRequest.* 前缀分类,限流会被吞进参数错误类。必须先精确匹配限流,
// 再 forbidden.* 前缀,最后兜底 api_error。未知 reason 按 api_error 兼容。
func classifyDriveDepthReason(errorCode string) string {
switch {
case errorCode == driveDepthRateLimitedCode:
return "rate_limited"
case strings.HasPrefix(errorCode, "forbidden."):
return "permission_denied"
default:
return "api_error"
}
}
func newDriveDepthError(folder driveDepthFolder, err error) driveDepthError {
return driveDepthError{
Depth: folder.depth,
FolderID: folder.id,
FolderName: folder.name,
Reason: classifyDriveDepthReason(driveDepthErrorCode(err)),
Message: driveDepthErrorMessage(err),
}
}
func driveDepthErrorMessage(err error) string {
var cliErr *CLIError
if errors.As(err, &cliErr) {
var body map[string]any
if json.Unmarshal([]byte(cliErr.Message), &body) == nil {
for _, key := range []string{"errorMsg", "message"} {
if s, ok := body[key].(string); ok && s != "" {
return s
}
}
}
return cliErr.Message
}
return err.Error()
}
+813
View File
@@ -0,0 +1,813 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func driveDepthTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
return cmd
}
func TestCrossPlatformCoverageDriveDepthCancelledError(t *testing.T) {
err := &driveDepthCancelledError{}
if err.Error() != driveDepthCancelledMsg || err.RawStderr() != driveDepthCancelledMsg {
t.Fatalf("messages = %q/%q", err.Error(), err.RawStderr())
}
if err.ExitCode() != 130 {
t.Fatalf("ExitCode = %d, want 130", err.ExitCode())
}
}
func TestCrossPlatformCoverageValidateDriveListDepth(t *testing.T) {
newCmd := func() *cobra.Command { return driveDepthTestCmd() }
if err := validateDriveListDepth(newCmd(), 0); err == nil {
t.Fatal("depth 0 returned nil")
}
if err := validateDriveListDepth(newCmd(), driveDepthMax+1); err == nil {
t.Fatal("depth above max returned nil")
}
shallow := newCmd()
_ = shallow.Flags().Set("cursor", "c1")
_ = shallow.Flags().Set("limit", "5")
if err := validateDriveListDepth(shallow, 1); err != nil {
t.Fatalf("depth 1 with cursor/limit returned %v", err)
}
withCursor := newCmd()
_ = withCursor.Flags().Set("cursor", "c1")
if err := validateDriveListDepth(withCursor, 2); err == nil {
t.Fatal("depth 2 with cursor returned nil")
}
withNextToken := newCmd()
_ = withNextToken.Flags().Set("next-token", "t1")
if err := validateDriveListDepth(withNextToken, 2); err == nil {
t.Fatal("depth 2 with next-token returned nil")
}
withLimit := newCmd()
_ = withLimit.Flags().Set("limit", "5")
if err := validateDriveListDepth(withLimit, 2); err == nil {
t.Fatal("depth 2 with limit returned nil")
}
withMax := newCmd()
_ = withMax.Flags().Set("max", "5")
if err := validateDriveListDepth(withMax, 2); err == nil {
t.Fatal("depth 2 with max returned nil")
}
if err := validateDriveListDepth(newCmd(), 2); err != nil {
t.Fatalf("depth 2 clean returned %v", err)
}
if err := validateDriveListDepth(newCmd(), driveDepthMax); err != nil {
t.Fatalf("depth max clean returned %v", err)
}
}
func TestCrossPlatformCoverageDriveDepthRoutesBuildArgs(t *testing.T) {
pan := newDrivePanDepthRoute()
args := pan.buildArgs(map[string]any{"spaceId": "s1"}, "folder1", "tok")
if args["maxResults"] != float64(driveDepthPageSize) || args["spaceId"] != "s1" ||
args["parentId"] != "folder1" || args["nextToken"] != "tok" {
t.Fatalf("pan args = %#v", args)
}
empty := pan.buildArgs(nil, "", "")
if _, ok := empty["parentId"]; ok {
t.Fatalf("pan empty folder args = %#v", empty)
}
if _, ok := empty["nextToken"]; ok {
t.Fatalf("pan empty token args = %#v", empty)
}
if id := pan.itemID(map[string]any{"fileId": "f1"}); id != "f1" {
t.Fatalf("pan itemID = %q", id)
}
if id := pan.itemID(map[string]any{}); id != "" {
t.Fatalf("pan missing itemID = %q", id)
}
doc := newDocDepthRoute()
dargs := doc.buildArgs(map[string]any{"workspaceId": "w1"}, "node1", "pt")
if dargs["pageSize"] != float64(docDepthPageSize) || dargs["workspaceId"] != "w1" ||
dargs["folderId"] != "node1" || dargs["pageToken"] != "pt" {
t.Fatalf("doc args = %#v", dargs)
}
dempty := doc.buildArgs(nil, "", "")
if _, ok := dempty["folderId"]; ok {
t.Fatalf("doc empty folder args = %#v", dempty)
}
if id := doc.itemID(map[string]any{"nodeId": "n1"}); id != "n1" {
t.Fatalf("doc itemID = %q", id)
}
if id := doc.itemID(map[string]any{}); id != "" {
t.Fatalf("doc missing itemID = %q", id)
}
}
func TestCrossPlatformCoverageParseDriveDepthPage(t *testing.T) {
if items, next, hasMore := parseDriveDepthPage(`{invalid`); items != nil || next != "" || hasMore {
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
}
if items, _, _ := parseDriveDepthPage(`{"foo":1}`); items != nil {
t.Fatalf("missing items key = %#v", items)
}
items, next, hasMore := parseDriveDepthPage(`{"items":[{"name":"a"},42],"nextToken":"t","hasMore":true}`)
if len(items) != 1 || items[0]["name"] != "a" || next != "t" || !hasMore {
t.Fatalf("top-level items = %#v %q %v", items, next, hasMore)
}
items, next, _ = parseDriveDepthPage(`{"result":{"dentryList":[{"name":"b"}],"nextToken":"t2"}}`)
if len(items) != 1 || items[0]["name"] != "b" || next != "t2" {
t.Fatalf("result dentryList = %#v %q", items, next)
}
if items, _, _ := parseDriveDepthPage(`{"result":{"other":1}}`); items != nil {
t.Fatalf("result without items = %#v", items)
}
}
func TestCrossPlatformCoverageDriveDepthListItemsKey(t *testing.T) {
if k := driveDepthListItemsKey(map[string]any{"items": []any{}}); k != "items" {
t.Fatalf("items key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{"dentryList": []any{1}}); k != "dentryList" {
t.Fatalf("dentryList key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{"items": nil}); k != "" {
t.Fatalf("nil items key = %q", k)
}
if k := driveDepthListItemsKey(map[string]any{}); k != "" {
t.Fatalf("missing key = %q", k)
}
}
func TestCrossPlatformCoverageParseDocDepthPage(t *testing.T) {
if items, next, hasMore := parseDocDepthPage(`{bad`); items != nil || next != "" || hasMore {
t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore)
}
items, next, hasMore := parseDocDepthPage(`{"nodes":[{"name":"a"},"x"],"nextPageToken":"nt","hasMore":true}`)
if len(items) != 1 || next != "nt" || !hasMore {
t.Fatalf("nodes = %#v %q %v", items, next, hasMore)
}
}
func TestCrossPlatformCoverageMatchDriveNamePattern(t *testing.T) {
if !matchDriveNamePattern("anything", "") {
t.Fatal("empty pattern should match")
}
if !matchDriveNamePattern("report.xlsx", "*.xlsx") || matchDriveNamePattern("report.csv", "*.xlsx") {
t.Fatal("glob match failed")
}
if !matchDriveNamePattern("weekly-report.xlsx", "report") || matchDriveNamePattern("a.txt", "report") {
t.Fatal("substring wrap failed")
}
if !matchDriveNamePattern("a[b", "[") {
t.Fatal("invalid pattern should fall back to substring")
}
}
func TestCrossPlatformCoverageDriveDepthItemClassifiers(t *testing.T) {
if !isDriveDepthFolder(map[string]any{"type": "FOLDER"}) {
t.Fatal("type FOLDER not detected")
}
if !isDriveDepthFolder(map[string]any{"dentryType": "folder"}) {
t.Fatal("dentryType folder not detected")
}
if isDriveDepthFolder(map[string]any{"type": "FILE"}) || isDriveDepthFolder(map[string]any{}) {
t.Fatal("non-folder detected as folder")
}
if !isDocDepthFolder(map[string]any{"nodeType": "Folder"}) {
t.Fatal("doc folder not detected")
}
if isDocDepthFolder(map[string]any{"nodeType": "doc"}) {
t.Fatal("doc detected as folder")
}
if id := driveDepthItemID(map[string]any{"fileId": "f"}); id != "f" {
t.Fatalf("fileId = %q", id)
}
if id := driveDepthItemID(map[string]any{"nodeId": "n"}); id != "n" {
t.Fatalf("nodeId = %q", id)
}
if id := driveDepthItemID(map[string]any{"fileId": ""}); id != "" {
t.Fatalf("empty = %q", id)
}
}
func TestCrossPlatformCoverageDriveDepthUnrecoverable(t *testing.T) {
for _, code := range []string{CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable} {
if !driveDepthUnrecoverable(&CLIError{Code: code}) {
t.Fatalf("%s not unrecoverable", code)
}
}
if driveDepthUnrecoverable(&CLIError{Code: CodeMCPToolError}) {
t.Fatal("tool error marked unrecoverable")
}
if driveDepthUnrecoverable(errors.New("plain")) {
t.Fatal("plain error marked unrecoverable")
}
}
func TestCrossPlatformCoverageDriveDepthErrorCode(t *testing.T) {
if c := driveDepthErrorCode(errors.New("plain")); c != "" {
t.Fatalf("plain = %q", c)
}
if c := driveDepthErrorCode(&CLIError{Message: "not json"}); c != "" {
t.Fatalf("non-JSON = %q", c)
}
for _, tc := range []struct{ body, want string }{
{`{"errorCode":"a.b"}`, "a.b"},
{`{"error_code":"c.d"}`, "c.d"},
{`{"code":"e.f"}`, "e.f"},
{`{"errorCode":""}`, ""},
} {
if c := driveDepthErrorCode(&CLIError{Message: tc.body}); c != tc.want {
t.Fatalf("%s = %q, want %q", tc.body, c, tc.want)
}
}
}
func TestCrossPlatformCoverageClassifyDriveDepthReason(t *testing.T) {
if r := classifyDriveDepthReason(driveDepthRateLimitedCode); r != "rate_limited" {
t.Fatalf("rate limit = %q", r)
}
if r := classifyDriveDepthReason("forbidden.noPermission"); r != "permission_denied" {
t.Fatalf("forbidden = %q", r)
}
if r := classifyDriveDepthReason("other.x"); r != "api_error" {
t.Fatalf("other = %q", r)
}
}
func TestCrossPlatformCoverageDriveDepthErrorMessage(t *testing.T) {
if m := driveDepthErrorMessage(&CLIError{Message: `{"errorMsg":"boom"}`}); m != "boom" {
t.Fatalf("errorMsg = %q", m)
}
if m := driveDepthErrorMessage(&CLIError{Message: `{"message":"m2"}`}); m != "m2" {
t.Fatalf("message = %q", m)
}
if m := driveDepthErrorMessage(&CLIError{Message: "raw text"}); m != "raw text" {
t.Fatalf("raw = %q", m)
}
if m := driveDepthErrorMessage(errors.New("plain boom")); m != "plain boom" {
t.Fatalf("plain = %q", m)
}
}
func TestCrossPlatformCoverageNewDriveDepthError(t *testing.T) {
folder := driveDepthFolder{id: "f1", name: "folder one", depth: 2}
derr := newDriveDepthError(folder, &CLIError{Code: CodeMCPToolError, Message: `{"errorCode":"forbidden.x","errorMsg":"denied"}`})
if derr.Depth != 2 || derr.FolderID != "f1" || derr.FolderName != "folder one" ||
derr.Reason != "permission_denied" || derr.Message != "denied" {
t.Fatalf("driveDepthError = %#v", derr)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func installDepthCaller(t *testing.T, caller *scriptedToolCaller) *bytes.Buffer {
t.Helper()
installScriptedCaller(t, caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
return buf
}
func decodeDepthResult(t *testing.T, buf *bytes.Buffer) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
return out
}
func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
out := installDepthCaller(t, &scriptedToolCaller{})
items := []map[string]any{
{"fileName": "b-file.xlsx", "rel_path": "b", "depth": 1, "fileId": "f2"},
{"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 {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
got := result["items"].([]any)
if len(got) != 2 {
t.Fatalf("filtered items = %#v", got)
}
if got[0].(map[string]any)["rel_path"] != "a" || got[1].(map[string]any)["rel_path"] != "b" {
t.Fatalf("sort order = %#v", got)
}
if result["maxDepth"] != float64(2) {
t.Fatalf("maxDepth = %#v", result["maxDepth"])
}
if result["truncated"] != false {
t.Fatalf("truncated = %#v", result["truncated"])
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
if items, ok := result["items"].([]any); !ok || len(items) != 0 {
t.Fatalf("nil items = %#v", result["items"])
}
samePath := []map[string]any{
{"name": "dup", "rel_path": "p/dup", "fileId": "z9"},
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
if got[0].(map[string]any)["fileId"] != "a1" {
t.Fatalf("tie-break order = %#v", got)
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
t.Fatal("failing writer returned nil")
}
}
func TestCrossPlatformCoveragePrintDriveDepthDryRun(t *testing.T) {
jsonOut := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true})
if err := printDriveDepthDryRun(newDrivePanDepthRoute(), map[string]any{"spaceId": "s1"}, 3); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
t.Fatalf("json dry-run output: %v", err)
}
if payload["dry_run"] != true || payload["tool"] != "list_files" || payload["maxDepth"] != float64(3) {
t.Fatalf("json dry-run = %#v", payload)
}
installDepthCaller(t, &scriptedToolCaller{format: "table", dry: true})
if err := printDriveDepthDryRun(newDocDepthRoute(), map[string]any{"workspaceId": "w1"}, 2); err != nil {
t.Fatal(err)
}
}
func useDriveDepthArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list", "--depth", "2"}
t.Cleanup(func() { os.Args = old })
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
return decodeDepthResult(t, out), err
}
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 {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output: %v", err)
}
if payload["dry_run"] != true {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"fY","name":"y.txt","type":"FILE"}]}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2", caller.calls)
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 3 {
t.Fatalf("items = %#v", items)
}
var dirA, nested map[string]any
for _, raw := range items {
item := raw.(map[string]any)
switch item["rel_path"] {
case "dirA":
dirA = item
case "dirA/y.txt":
nested = item
}
}
if dirA == nil || nested == nil {
t.Fatalf("rel_path tree = %#v", items)
}
if dirA["depth"] != float64(1) || dirA["parentId"] != "" {
t.Fatalf("root item = %#v", dirA)
}
if nested["depth"] != float64(2) || nested["parentId"] != "fA" {
t.Fatalf("nested item = %#v", nested)
}
}
func TestCrossPlatformCoverageRunDriveListDepthMaxDepthSkipsEnqueue(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 1, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
if len(result["items"].([]any)) != 1 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthDedup(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER"},
{"fileId":"fA","name":"dirA-copy","type":"FOLDER"},
{"fileId":"","name":"no-id","type":"FOLDER"},
{"fileId":"fB","name":"dirB","type":"FOLDER"}]}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3 (dedup + empty id skipped)", caller.calls)
}
if len(result["items"].([]any)) != 6 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthDocRouteAnomaly(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"nB","name":"folderB","nodeType":"folder"},{"nodeId":"nD","name":"doc1","nodeType":"doc"}],"hasMore":false,"nextPageToken":"ignored"}`},
{text: `{"nodes":[{"nodeId":"nE","name":"inner","nodeType":"doc"}],"hasMore":true}`},
}}
result, err := runDepthBFS(t, caller, newDocDepthRoute(), "root1", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (hasMore=false stops authoritative route)", caller.calls)
}
errs := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("errors = %#v", result["errors"])
}
entry := errs[0].(map[string]any)
if entry["folderId"] != "nB" || entry["reason"] != "api_error" {
t.Fatalf("anomaly entry = %#v", entry)
}
if !strings.Contains(entry["message"].(string), "hasMore=true but nextToken is empty") {
t.Fatalf("anomaly message = %#v", entry)
}
if len(result["items"].([]any)) != 3 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitRetry(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3", caller.calls)
}
if errs := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if len(result["items"].([]any)) != 2 {
t.Fatalf("items = %#v", result["items"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t *testing.T) {
useDriveDepthArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"page1.txt","type":"FILE"}],"nextToken":"p2"}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
{text: `{"items":[{"fileId":"f2","name":"page2.txt","type":"FILE"}]}`},
}}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
out := &bytes.Buffer{}
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 {
t.Fatal(err)
}
if len(caller.calls) != 3 {
t.Fatalf("calls = %d, want 3", len(caller.calls))
}
if tok, _ := caller.calls[2]["nextToken"].(string); tok != "p2" {
t.Fatalf("retry call args = %#v, want resume with nextToken=p2", caller.calls[2])
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 2 {
t.Fatalf("items = %#v, want 2 without duplicates", items)
}
}
func TestCrossPlatformCoverageRunDriveListDepthRateLimitExhausted(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`},
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("calls = %d, want 3 (one retry)", caller.calls)
}
errs := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("errors = %#v", errs)
}
entry := errs[0].(map[string]any)
if entry["reason"] != "rate_limited" || entry["folderId"] != "fA" {
t.Fatalf("rate limit entry = %#v", entry)
}
}
func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`},
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("root failure returned nil")
}
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2 (single retry then fail)", caller.calls)
}
}
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("unrecoverable returned nil")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("err = %T %v", err, err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 2 {
t.Fatalf("partial items = %#v", result["items"])
}
if len(result["errors"].([]any)) != 1 {
t.Fatalf("errors = %#v", result["errors"])
}
}
func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[],"nextToken":"t","hasMore":true}`},
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
maxPages := driveDepthMaxItems/driveDepthPageSize + 1
if caller.calls != maxPages {
t.Fatalf("calls = %d, want %d", caller.calls, maxPages)
}
}
func TestCrossPlatformCoverageRunDriveListDepthTruncation(t *testing.T) {
useDriveDepthArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE"}`, i, i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if len(result["items"].([]any)) != driveDepthMaxItems {
t.Fatalf("items len = %d", len(result["items"].([]any)))
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{}
out := installDepthCaller(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if result["truncated"] != true {
t.Fatalf("cancelled result = %#v", result)
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
}}
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
}
func TestCrossPlatformCoverageRunDriveListDepthPatternFilter(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
{text: `{"items":[{"fileId":"fY","name":"keep.xlsx","type":"FILE"}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "*.xlsx")
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["name"] != "keep.xlsx" {
t.Fatalf("filtered = %#v", items)
}
}
type cancelOnCallCaller struct {
scriptedToolCaller
cancel context.CancelFunc
}
func (c *cancelOnCallCaller) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.cancel()
return c.scriptedToolCaller.CallTool(ctx, productID, toolName, args)
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *testing.T) {
useDriveDepthArgs(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
route := newDrivePanDepthRoute()
baseParse := route.parsePage
route.parsePage = func(text string) ([]map[string]any, string, bool) {
cancel()
return baseParse(text)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}],"nextToken":"more"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 1 || result["truncated"] != true {
t.Fatalf("cancelled partial = %#v", result)
}
}
func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T) {
useDriveDepthArgs(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
caller := &cancelOnCallCaller{
scriptedToolCaller: scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`},
}},
cancel: cancel,
}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
result := decodeDepthResult(t, out)
if result["truncated"] != true {
t.Fatalf("cancelled result = %#v", result)
}
}
+227
View File
@@ -0,0 +1,227 @@
package helpers
import (
"context"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type depthArgsRecordingCaller struct {
steps []scriptedToolStep
index int
calls []map[string]any
}
func (c *depthArgsRecordingCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, args)
index := c.index
if index >= len(c.steps) {
index = len(c.steps) - 1
}
c.index++
return textToolResult(c.steps[index].text), nil
}
func (*depthArgsRecordingCaller) Format() string { return "json" }
func (*depthArgsRecordingCaller) DryRun() bool { return false }
func (*depthArgsRecordingCaller) Fields() string { return "" }
func (*depthArgsRecordingCaller) JQ() string { return "" }
func executeDriveCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDriveListVersionsMode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "5", "--cursor", "cur-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "list_file_versions" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["maxResults"] != 5 || call.args["nextCursor"] != "cur-1" {
t.Fatalf("args = %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveListVersionsModeRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "list", "--versions")
if err == nil {
t.Fatal("versions without --node returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListWorkspaceDepthRoutesDocBFS(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller,
"list", "--workspace", "ws-1", "--depth", "2", "--node", "folder-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["workspaceId"] != "ws-1" || args["folderId"] != "folder-1" || args["pageSize"] != float64(docDepthPageSize) {
t.Fatalf("doc BFS args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListWorkspaceDepthRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--workspace", "ws-1", "--depth", "2", "--node", "12345")
if err == nil {
t.Fatal("numeric doc folder returned nil")
}
}
func TestCrossPlatformCoverageDriveListPanDepthBuildsBaseArgs(t *testing.T) {
useDriveDepthArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--depth", "2", "--space-id", "sp-1", "--order-by", "name", "--order", "asc", "--thumbnail", "--folder", "root-folder")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["spaceId"] != "sp-1" || args["orderBy"] != "name" || args["order"] != "asc" ||
args["withThumbnail"] != true || args["parentId"] != "root-folder" || args["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("pan BFS args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListPanDepthRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--depth", "2", "--folder", "12345")
if err == nil {
t.Fatal("numeric drive folder returned nil")
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRun(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerYesRequiresRecursive(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--yes")
if err == nil || !strings.Contains(err.Error(), "--recursive is required") {
t.Fatalf("err = %v, want --recursive required", err)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerWorkspaceTarget(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--workspace", "ws-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--recursive", "--yes")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.productID != "doc" || call.toolName != "transfer_owner" {
t.Fatalf("call = %#v", call)
}
if call.args["workspaceId"] != "ws-1" || call.args["newOwnerId"] != "user-1" ||
call.args["reserveOldOwnerRole"] != "EDITOR" || call.args["recursiveChange"] != true {
t.Fatalf("args = %#v", call.args)
}
if _, ok := call.args["nodeId"]; ok {
t.Fatalf("unexpected nodeId in %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveCoverRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "cover")
if err == nil {
t.Fatal("cover without --node returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveRevertDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"revert", "--node", "node-1", "--version", "3")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined revert calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerRejectsBothTargets(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--workspace", "ws-1", "--new-owner", "user-1")
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err = %v, want mutual exclusion", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
+263
View File
@@ -0,0 +1,263 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// executeDriveCommandCapture 执行 drive 命令并捕获 deps.Out 的 stdout 输出,
// 供需要断言 JSON 输出形态的测试使用(等价于 installDepthCaller + Execute)。
func executeDriveCommandCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
buf := &bytes.Buffer{}
deps.Out.w = buf
deps.Out.errW = io.Discard
root := newDriveCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return buf, err
}
// ── drive permission apply:确认门禁与帮助文案契约一致 ──
func TestCrossPlatformCoverageDrivePermissionApplyDeclined(t *testing.T) {
caller := &guardedMutationCaller{}
root := newDriveCommand()
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "apply", "--node", "node-1", "--role", "READER", "--users", "u1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("declined apply calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDrivePermissionApplyYesProceeds(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "apply", "--node", "node-1", "--role", "reader", "--users", "u1,u2", "--yes")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "apply_permission" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["roleId"] != "READER" {
t.Fatalf("args = %#v", call.args)
}
if !reflect.DeepEqual(call.args["receivers"], []string{"u1", "u2"}) {
t.Fatalf("receivers = %#v", call.args["receivers"])
}
}
func TestCrossPlatformCoverageDrivePermissionApplyDryRunNoPromptNoCall(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
root := newDriveCommand()
promptOut := &bytes.Buffer{}
root.SetErr(promptOut)
root.SetIn(strings.NewReader("no\n"))
err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root },
"permission", "apply", "--node", "node-1", "--role", "READER", "--users", "u1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run apply calls = %#v, want none", caller.calls)
}
if strings.Contains(promptOut.String(), "Confirm action") {
t.Fatalf("dry-run prompted for confirmation: %q", promptOut.String())
}
}
// ── drive permission transfer-owner:dry-run JSON 输出且校验先于 dry-run ──
func TestCrossPlatformCoverageDriveTransferOwnerDryRunJSONNode(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run tool calls = %d, want none", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out.String())
}
if payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("payload = %#v", payload)
}
if payload["operation"] != "转交所有者" || payload["newOwnerId"] != "user-1" || payload["nodeId"] != "node-1" {
t.Fatalf("payload = %#v", payload)
}
if _, ok := payload["workspaceId"]; ok {
t.Fatalf("unexpected workspaceId in %#v", payload)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunJSONWorkspace(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--workspace", "ws-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out.String())
}
if payload["workspaceId"] != "ws-1" || payload["newOwnerId"] != "user-1" {
t.Fatalf("payload = %#v", payload)
}
if _, ok := payload["nodeId"]; ok {
t.Fatalf("unexpected nodeId in %#v", payload)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunYesValidatesFirst(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--yes", "--dry-run")
if err == nil || !strings.Contains(err.Error(), "--reserve-role is required") {
t.Fatalf("err = %v, want --reserve-role required even under --dry-run", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveTransferOwnerDryRunNonJSON(t *testing.T) {
caller := &scriptedToolCaller{format: "table", dry: true}
_, err := executeDriveCommandCapture(t, caller,
"permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run")
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run tool calls = %d, want none", caller.calls)
}
}
// ── drive list:--versions 与 --depth/--pattern 的交互 ──
func TestCrossPlatformCoverageDriveListVersionsRejectsDepth(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--depth", "2", "--limit", "10")
if err == nil || !strings.Contains(err.Error(), "--depth") {
t.Fatalf("err = %v, want explicit --versions/--depth conflict", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsRejectsPattern(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--pattern", "x")
if err == nil || !strings.Contains(err.Error(), "--pattern") {
t.Fatalf("err = %v, want explicit --versions/--pattern conflict", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsAllowsDepthOne(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--depth", "1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_file_versions" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListVersionsWithLimit(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "10")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "list_file_versions" {
t.Fatalf("call = %#v", call)
}
if call.args["nodeId"] != "node-1" || call.args["maxResults"] != 10 {
t.Fatalf("args = %#v", call.args)
}
}
func TestCrossPlatformCoverageDriveDownloadVersionDryRun(t *testing.T) {
caller := &guardedMutationCaller{dryRun: true}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"download-version", "--node", "node-1", "--version", "3", "--output", "./x.pdf", "--dry-run")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageDriveDownloadVersionDirectoryOutput(t *testing.T) {
SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return nil })
t.Cleanup(func() { SetHTTPGetFile(nil) })
dir := t.TempDir()
for _, resp := range []string{
`{"downloadUrl":"https://oss.test/get/report_v3.pdf","fileName":"报告v3.pdf"}`,
`{"downloadUrl":"https://oss.test/get/inferred_v3.pdf"}`,
} {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: resp}}}
installScriptedCaller(t, caller)
root := newDriveCommand()
root.PersistentFlags().Bool("dry-run", false, "")
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"download-version", "--node", "node-1", "--version", "3", "--output", dir})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
}
@@ -0,0 +1,168 @@
package helpers
import (
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageDriveStarCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "add",
args: []string{"star", "add", "--node", "node-1"},
want: guardedMutationCall{
productID: "drive",
toolName: "mark_star",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "add with url value passed through",
args: []string{"star", "add", "--node", "https://alidocs.dingtalk.com/i/nodes/node-2"},
want: guardedMutationCall{
productID: "drive",
toolName: "mark_star",
args: map[string]any{"nodeId": "https://alidocs.dingtalk.com/i/nodes/node-2"},
},
},
{
name: "remove",
args: []string{"star", "remove", "--node", "node-3"},
want: guardedMutationCall{
productID: "drive",
toolName: "unmark_star",
args: map[string]any{"nodeId": "node-3"},
},
},
{
name: "remove rm alias",
args: []string{"star", "rm", "--node", "node-4"},
want: guardedMutationCall{
productID: "drive",
toolName: "unmark_star",
args: map[string]any{"nodeId": "node-4"},
},
},
{
name: "list without filters",
args: []string{"star", "list"},
want: guardedMutationCall{
productID: "drive",
toolName: "get_star_list",
args: map[string]any{},
},
},
{
name: "list with all filters",
args: []string{
"star", "list",
"--limit", "10",
"--cursor", "cur-1",
"--order-by", "createTime",
"--sort", "desc",
"--resource-types", "DENTRY,WORKSPACE",
"--content-types", "doc,sheet",
},
want: guardedMutationCall{
productID: "drive",
toolName: "get_star_list",
args: map[string]any{
"limit": 10,
"cursor": "cur-1",
"orderBy": "createTime",
"sortType": "desc",
"supportResourceTypes": []string{"DENTRY", "WORKSPACE"},
"contentTypes": []string{"doc", "sheet"},
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, test.args...)
if err != nil {
t.Fatalf("drive %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageDriveStarRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "add without node",
args: []string{"star", "add"},
wantErr: "--node",
},
{
name: "remove without node",
args: []string{"star", "remove"},
wantErr: "--node",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageDriveStarListRecordsRawArgs(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"nodeId":"n1","name":"doc1"}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller,
"star", "list", "--limit", "5", "--cursor", "cur-9", "--sort", "asc")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["limit"] != 5 || args["cursor"] != "cur-9" || args["sortType"] != "asc" {
t.Fatalf("star list raw args = %#v", args)
}
if _, ok := args["orderBy"]; ok {
t.Fatalf("unexpected orderBy in %#v", args)
}
}
func TestCrossPlatformCoverageDriveStarAddScriptedSuccess(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"success":true}`},
}}
installScriptedCaller(t, caller)
root := newDriveCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"star", "add", "--node", "node-1"})
if err := root.Execute(); err != nil {
t.Fatalf("star add returned error: %v", err)
}
if caller.calls != 1 {
t.Fatalf("tool calls = %d, want 1", caller.calls)
}
}
+6 -5
View File
@@ -144,11 +144,12 @@ func (f *Formatter) PrintTable(headers []string, rows [][]string) {
fmt.Fprintf(f.w, "共 %d 条\n", len(rows))
}
func (f *Formatter) PrintSuccess(msg string) { fmt.Fprintf(f.w, "[OK] %s\n", msg) }
func (f *Formatter) PrintError(msg string) { fmt.Fprintf(f.w, "[ERROR] %s\n", msg) }
func (f *Formatter) PrintWarning(msg string) { fmt.Fprintf(f.errW, "[WARN] %s\n", msg) }
func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
func (f *Formatter) PrintSuccess(msg string) { fmt.Fprintf(f.w, "[OK] %s\n", msg) }
func (f *Formatter) PrintError(msg string) { fmt.Fprintf(f.w, "[ERROR] %s\n", msg) }
func (f *Formatter) PrintWarning(msg string) { fmt.Fprintf(f.errW, "[WARN] %s\n", msg) }
func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n", msg) }
func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
func (f *Formatter) PrintKeyValue(key, value string) {
fmt.Fprintf(f.w, "%-16s%s\n", key+":", value)
+3
View File
@@ -151,6 +151,7 @@ func newSheetCommand() *cobra.Command {
// Add all to root
root.AddCommand(standaloneCmds...)
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
root.AddCommand(newSheetVersionCmd(), newSheetCommentCmd(), newSheetFormulaVerifyCmd())
// This is the reviewed runtime counterpart of the final Sheet Schema
// confirmation=user_required set. It is intentionally command-local: there
@@ -175,6 +176,8 @@ func newSheetCommand() *cobra.Command {
{path: "filter-view delete-criteria", operation: "删除筛选视图列条件", targetHint: "文档、工作表、筛选视图和列"},
{path: "range batch-clear", operation: "批量清除工作表区域", targetHint: "文档、工作表、清除范围和清除类型"},
{path: "range move-to", operation: "移动工作表区域", targetHint: "源工作表范围和目标位置"},
{path: "version revert", operation: "回滚表格版本", targetHint: "文档和目标版本号"},
{path: "comment delete", operation: "删除单元格评论", targetHint: "文档和评论 commentKey"},
}
for _, guard := range confirmationGuards {
attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint)
+172
View File
@@ -0,0 +1,172 @@
package helpers
import (
"github.com/spf13/cobra"
)
func newSheetCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
Use: "comment",
Short: "表格评论 / 单元格评论管理",
Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`,
RunE: groupRunE,
}
commentListCmd := &cobra.Command{
Use: "list",
Short: "查询表格评论列表",
Example: ` dws sheet comment list --node <SHEET_ID>
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
toolArgs["pageSize"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
toolArgs["nextToken"] = v
}
if v, _ := cmd.Flags().GetString("resolve-status"); v != "" {
toolArgs["resolveStatus"] = v
}
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
toolArgs["sheetId"] = v
}
if v, _ := cmd.Flags().GetString("range"); v != "" {
toolArgs["range"] = v
}
return callMCPToolOnServer("doc-comment", "list_sheet_comments", toolArgs)
},
}
commentListCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentListCmd.Flags().Int("limit", 50, "每页返回的评论数量,默认 50,最大 50")
commentListCmd.Flags().String("cursor", "", "分页游标")
commentListCmd.Flags().String("resolve-status", "", "按解决状态过滤: resolved / unresolved")
commentListCmd.Flags().String("sheet-id", "", "工作表 ID 或名称(与 --range 一起按单元格过滤)")
commentListCmd.Flags().String("range", "", "单元格位置 A1 表示法(与 --sheet-id 一起按单元格过滤)")
commentCreateCmd := &cobra.Command{
Use: "create",
Short: "创建单元格评论",
Example: ` dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题"
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请确认" --mention uid1,uid2`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "sheet-id", "range"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"content": mustGetFlag(cmd, "content"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
}
return callMCPToolOnServer("doc-comment", "create_sheet_comment", toolArgs)
},
}
commentCreateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentCreateCmd.Flags().String("content", "", "评论内容 (必填)")
commentCreateCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
commentCreateCmd.Flags().String("range", "", "单元格位置 A1 表示法 (必填)")
commentCreateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
commentReplyCmd := &cobra.Command{
Use: "reply",
Short: "回复单元格评论",
Example: ` dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实"
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "comment-key"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"content": mustGetFlag(cmd, "content"),
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
}
return callMCPToolOnServer("doc-comment", "reply_comment", toolArgs)
},
}
commentReplyCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentReplyCmd.Flags().String("content", "", "回复内容 (必填)")
commentReplyCmd.Flags().String("comment-key", "", "被回复评论的 commentKey (必填)")
commentReplyCmd.Flags().Bool("emoji", false, "作为表情贴图回复")
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
commentUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新单元格评论",
Example: ` dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "content", "comment-key"); err != nil {
return err
}
return callMCPToolOnServer("doc-comment", "update_comment", map[string]any{
"nodeId": nodeID,
"commentKey": mustGetFlag(cmd, "comment-key"),
"content": mustGetFlag(cmd, "content"),
})
},
}
commentUpdateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey (必填)")
commentUpdateCmd.Flags().String("content", "", "更新后的评论内容 (必填)")
commentDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除单元格评论",
Example: ` dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
return err
}
commentKey := mustGetFlag(cmd, "comment-key")
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
"nodeId": nodeID,
"commentKey": commentKey,
})
},
}
commentDeleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey (必填)")
for _, c := range []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd)
return commentCmd
}
+156
View File
@@ -0,0 +1,156 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
)
func newSheetFormulaVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "formula-verify",
Short: "校验表格公式错误",
Long: `扫描钉钉电子表格中已落表的公式单元格,按计算结果错误类型聚合返回错误数量、位置和样本。
不指定 --sheet-id / --range / --targets 时默认扫描整本表格的全部工作表。`,
Example: ` dws sheet formula-verify --node NODE_ID
dws sheet formula-verify --node NODE_ID --sheet-id Sheet1 --range A1:D100
dws sheet formula-verify --node NODE_ID --targets '[{"sheetId":"Sheet1","range":"A1:D100"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node"); err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
}
targets, err := formulaVerifyTargetsFromFlags(cmd)
if err != nil {
return err
}
if len(targets) > 0 {
toolArgs["targets"] = targets
}
if cmd.Flags().Changed("max-locations-per-error") {
v, _ := cmd.Flags().GetInt("max-locations-per-error")
if v <= 0 {
return fmt.Errorf("--max-locations-per-error 必须是正整数")
}
toolArgs["maxLocationsPerError"] = v
}
if cmd.Flags().Changed("max-cells") {
v, _ := cmd.Flags().GetInt("max-cells")
if v <= 0 {
return fmt.Errorf("--max-cells 必须是正整数")
}
toolArgs["maxCells"] = v
}
exitOnError, _ := cmd.Flags().GetBool("exit-on-error")
return callMCPToolFormulaVerify(toolArgs, exitOnError)
},
}
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称;与 --range 组成单个扫描目标")
cmd.Flags().String("range", "", "A1 范围;需与 --sheet-id 配合使用")
cmd.Flags().String("targets", "", `扫描目标 JSON 数组、@文件路径 或 - 表示 stdin;每项 {"sheetId":"Sheet1","range":"A1:D100"}`)
cmd.Flags().Int("max-locations-per-error", 0, "每种错误类型最多返回的位置数")
cmd.Flags().Int("max-cells", 0, "最多扫描的单元格数")
cmd.Flags().Bool("exit-on-error", false, "发现公式错误时返回非 0 退出码,便于 CI/自动化使用")
return cmd
}
// callMCPToolFormulaVerify 与常规打印路径的差异:--exit-on-error 需要读取
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
}
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
if err != nil {
return err
}
if text == "" {
return nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
deps.Out.PrintRaw(text)
return nil
}
if parsed == nil {
return fmt.Errorf("formula_verify returned empty result")
}
if err := deps.Out.PrintJSON(parsed); err != nil {
return err
}
if exitOnError && formulaVerifyHasErrors(parsed) {
return fmt.Errorf("formula errors found")
}
return nil
}
func formulaVerifyHasErrors(parsed map[string]any) bool {
result := formulaVerifyResultObject(parsed)
status := strings.ToLower(strings.TrimSpace(fmt.Sprint(result["status"])))
if status == "errors_found" {
return true
}
totalErrors, ok := nonNegativeJSONInt(result["totalErrors"])
return ok && totalErrors > 0
}
func formulaVerifyResultObject(parsed map[string]any) map[string]any {
if result, ok := parsed["result"].(map[string]any); ok {
return result
}
return parsed
}
func formulaVerifyTargetsFromFlags(cmd *cobra.Command) ([]map[string]any, error) {
sheetID, _ := cmd.Flags().GetString("sheet-id")
rangeStr, _ := cmd.Flags().GetString("range")
if v, _ := cmd.Flags().GetString("targets"); v != "" {
if strings.TrimSpace(sheetID) != "" || strings.TrimSpace(rangeStr) != "" {
return nil, fmt.Errorf("--targets 不能与 --sheet-id 或 --range 同时使用")
}
return parseFormulaVerifyTargets(cmd, v)
}
if sheetID == "" && rangeStr != "" {
return nil, fmt.Errorf("--range 必须与 --sheet-id 配合使用")
}
if sheetID != "" {
t := map[string]any{"sheetId": sheetID}
if rangeStr != "" {
t["range"] = rangeStr
}
return []map[string]any{t}, nil
}
return nil, nil
}
func parseFormulaVerifyTargets(cmd *cobra.Command, raw string) ([]map[string]any, error) {
data := raw
if strings.HasPrefix(raw, "@") {
filePath := strings.TrimPrefix(raw, "@")
content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取 --targets 文件失败: %w", err)
}
data = string(content)
} else if raw == "-" {
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("读取 stdin 失败: %w", err)
}
data = string(content)
}
var targets []map[string]any
if err := json.Unmarshal([]byte(data), &targets); err != nil {
return nil, fmt.Errorf("--targets JSON 解析失败: %w", err)
}
return targets, nil
}
@@ -0,0 +1,175 @@
package helpers
import (
"os"
"path/filepath"
"strings"
"testing"
)
func executeFormulaVerify(t *testing.T, caller *scriptedToolCaller, stdin *strings.Reader, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
if stdin != nil {
cmd.SetIn(stdin)
}
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageSheetFormulaVerifyRejectsNonPositiveLimits(t *testing.T) {
if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--max-locations-per-error", "0"); err == nil {
t.Fatal("max-locations-per-error 0 returned nil")
}
if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--max-cells", "-1"); err == nil {
t.Fatal("max-cells -1 returned nil")
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeFormulaVerify(t, caller, nil,
"--node", "n1", "--max-locations-per-error", "3", "--max-cells", "100",
"--targets", `[{"sheetId":"Sheet1","range":"A1:D10"}]`)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "targets.json")
if err := os.WriteFile(path, []byte(`[{"sheetId":"Sheet1"}]`), 0o600); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--targets", "@"+path); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFileMissing(t *testing.T) {
err := executeFormulaVerify(t, &scriptedToolCaller{}, nil,
"--node", "n1", "--targets", "@/nonexistent/targets.json")
if err == nil || !strings.Contains(err.Error(), "读取 --targets 文件失败") {
t.Fatalf("err = %v, want file read failure", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromStdin(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeFormulaVerify(t, caller, strings.NewReader(`[{"sheetId":"S1"}]`),
"--node", "n1", "--targets", "-"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsStdinFailure(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetIn(failingReader{})
cmd.SetArgs([]string{"--node", "n1", "--targets", "-"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
t.Fatalf("err = %v, want stdin failure", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsConflict(t *testing.T) {
for _, extra := range [][]string{
{"--sheet-id", "Sheet1"},
{"--range", "A1:B2"},
} {
args := append([]string{"--node", "n1", "--targets", `[{"sheetId":"S1"}]`}, extra...)
err := executeFormulaVerify(t, &scriptedToolCaller{}, nil, args...)
if err == nil || !strings.Contains(err.Error(), "--targets 不能与 --sheet-id 或 --range 同时使用") {
t.Fatalf("args %v err = %v, want conflict error", extra, err)
}
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyExitOnError(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"ERRORS_FOUND","totalErrors":2}`},
}}
err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error")
if err == nil || !strings.Contains(err.Error(), "formula errors found") {
t.Fatalf("err = %v, want formula errors found", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"status":"partial","totalErrors":1}}`},
}}
err = executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error")
if err == nil || !strings.Contains(err.Error(), "formula errors found") {
t.Fatalf("nested totalErrors err = %v, want formula errors found", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"clean","totalErrors":0}`},
}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatalf("clean result err = %v", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"status":"errors_found"}`},
}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1"); err != nil {
t.Fatalf("errors without --exit-on-error err = %v", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyPayloadEdges(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `plain text result`}}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatalf("non-JSON payload err = %v", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `null`}}}
err := executeFormulaVerify(t, caller, nil, "--node", "n1")
if err == nil || !strings.Contains(err.Error(), "empty result") {
t.Fatalf("null payload err = %v, want empty result", err)
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"forbidden.x","errorMsg":"denied"}`}}}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1"); err == nil {
t.Fatal("business error returned nil")
}
caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"status":"clean"}`}}}
installScriptedCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := newSheetFormulaVerifyCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--node", "n1"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("print failure err = %v, want write failed", err)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyDryRunShortCircuit(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--exit-on-error"); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run calls = %d, want 0 (preview only)", caller.calls)
}
}
@@ -0,0 +1,40 @@
package helpers
import (
"io"
"os"
"testing"
)
func TestCrossPlatformCoverageSheetInfoIncludePassthrough(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = oldArgs })
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"sheetId":"s1"}`},
}}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newSheetCommand()
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "")
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"info", "--node", "n1", "--sheet-id", "Sheet1", "--include", "row_heights,col_widths"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
include, _ := caller.calls[0]["include"].([]string)
if len(include) != 2 || include[0] != "row_heights" || include[1] != "col_widths" {
t.Fatalf("include = %#v", caller.calls[0]["include"])
}
}
@@ -0,0 +1,298 @@
package helpers
import (
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "list minimal",
args: []string{"comment", "list", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "list_sheet_comments",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list with cell and status filters",
args: []string{
"comment", "list",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "A2",
"--resolve-status", "unresolved",
"--limit", "10",
"--cursor", "cur-1",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "list_sheet_comments",
args: map[string]any{
"nodeId": "node-1",
"sheetId": "Sheet1",
"range": "A2",
"resolveStatus": "unresolved",
"pageSize": 10,
"nextToken": "cur-1",
},
},
},
{
name: "create with mention parsing",
args: []string{
"comment", "create",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "A2",
"--content", "check this",
"--mention", "uid1, uid2 ,,uid3",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "create_sheet_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "check this",
"sheetId": "Sheet1",
"range": "A2",
"mentionedUserIds": []string{"uid1", "uid2", "uid3"},
},
},
},
{
name: "create without mention",
args: []string{
"comment", "create",
"--node", "node-1",
"--sheet-id", "Sheet1",
"--range", "B3",
"--content", "plain",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "create_sheet_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "plain",
"sheetId": "Sheet1",
"range": "B3",
},
},
},
{
name: "reply with emoji and mention",
args: []string{
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--emoji",
"--mention", "uid1",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
},
},
},
{
name: "reply plain text",
args: []string{
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-2",
"--content", "confirmed",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "confirmed",
"replyCommentKey": "ck-2",
},
},
},
{
name: "update",
args: []string{
"comment", "update",
"--node", "node-1",
"--comment-key", "ck-3",
"--content", "revised",
},
want: guardedMutationCall{
productID: "doc-comment",
toolName: "update_comment",
args: map[string]any{
"nodeId": "node-1",
"commentKey": "ck-3",
"content": "revised",
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err != nil {
t.Fatalf("sheet %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageSheetCommentRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "list without node",
args: []string{"comment", "list"},
wantErr: "--node",
},
{
name: "create without content sheet-id range",
args: []string{"comment", "create", "--node", "node-1"},
wantErr: "--content, --sheet-id, --range",
},
{
name: "reply without comment-key",
args: []string{"comment", "reply", "--node", "node-1", "--content", "text"},
wantErr: "--comment-key",
},
{
name: "update without content",
args: []string{"comment", "update", "--node", "node-1", "--comment-key", "ck-1"},
wantErr: "--content",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageSheetVersionCommands(t *testing.T) {
tests := []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "save",
args: []string{"version", "save", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "save_doc_version",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list minimal",
args: []string{"version", "list", "--node", "node-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{"nodeId": "node-1"},
},
},
{
name: "list with pagination pass-through",
args: []string{"version", "list", "--node", "node-1", "--limit", "10", "--cursor", "cur-1"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{
"nodeId": "node-1",
"maxResults": 10,
"nextCursor": "cur-1",
},
},
},
{
name: "list ls alias",
args: []string{"version", "ls", "--node", "node-2"},
want: guardedMutationCall{
productID: "doc",
toolName: "list_doc_versions",
args: map[string]any{"nodeId": "node-2"},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err != nil {
t.Fatalf("sheet %s returned error: %v", strings.Join(test.args, " "), err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
func TestCrossPlatformCoverageSheetVersionRequiredFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "save without node",
args: []string{"version", "save"},
wantErr: "--node",
},
{
name: "list without node",
args: []string{"version", "list"},
wantErr: "--node",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want message containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
+95
View File
@@ -0,0 +1,95 @@
package helpers
import (
"fmt"
"github.com/spf13/cobra"
)
func newSheetVersionCmd() *cobra.Command {
versionCmd := &cobra.Command{
Use: "version",
Short: "表格历史版本管理",
Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
versionSaveCmd := &cobra.Command{
Use: "save",
Short: "手动保存表格版本快照",
Example: ` dws sheet version save --node SHEET_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("doc", "save_doc_version", map[string]any{
"nodeId": nodeID,
})
},
}
versionSaveCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查看表格历史版本列表",
Example: ` dws sheet version list --node SHEET_ID
dws sheet version list --node SHEET_ID --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPToolOnServer("doc", "list_doc_versions", toolArgs)
},
}
versionListCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionListCmd.Flags().Int("limit", 0, "返回版本数量上限")
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚表格到指定版本",
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if !cmd.Flags().Changed("version") {
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
"version": version,
})
},
}
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
c.Flags().String("doc-id", "", "")
c.Flags().String("file-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
return versionCmd
}
+6 -1
View File
@@ -69,7 +69,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
不传 --sheet-id 时默认返回第一个工作表。`,
Example: ` dws sheet info --node NODE_ID
dws sheet info --node NODE_ID --sheet-id SHEET_ID
dws sheet info --node NODE_ID --sheet-id "Sheet1"`,
dws sheet info --node NODE_ID --sheet-id "Sheet1"
dws sheet info --node NODE_ID --sheet-id SHEET_ID --include row_heights,col_widths`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
@@ -77,11 +78,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
toolArgs["sheetId"] = v
}
if v, _ := cmd.Flags().GetStringSlice("include"); len(v) > 0 {
toolArgs["include"] = v
}
return callMCPToolSheetInfo(toolArgs)
},
}
infoCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
infoCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (不传则返回第一个工作表)")
infoCmd.Flags().StringSlice("include", nil, "可选扩展信息,逗号分隔;支持 groups / row_heights / col_widths / hidden_rows / hidden_cols / frozen")
newCmd := &cobra.Command{
Use: "new",
+6 -4
View File
@@ -55,10 +55,12 @@ Host-owned PAT 开关:
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
CLI 侧不再拉起任何本地浏览器 / 轮询。
服务端路由标签 claw-type(开源构建硬编码):
开源构建在所有出站 MCP 请求上恒定注入 claw-type: openClaw,
与 DINGTALK_AGENT / 宿主环境解耦,与历史 main 行为一致。
hostControl.clawType 也会回填该值,便于宿主侧审计/路由。
服务端 Agent 产品标签 claw-type:
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
+76
View File
@@ -0,0 +1,76 @@
// 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 agentproduct defines the caller-provided Agent product identity
// carried on outbound DWS requests.
package agentproduct
import (
"errors"
"os"
"regexp"
"strings"
)
const (
// EnvName is the runtime override for the Agent product identity.
EnvName = "DWS_AGENT_PRODUCT"
// HeaderName is the existing wire header used for the Agent product.
HeaderName = "claw-type"
// MaxValueBytes bounds the value because it is attached to every outbound
// MCP request. Supported values are ASCII, so bytes and characters match.
MaxValueBytes = 64
)
// ErrInvalid is returned when an Agent product value is unsafe or does not
// match the supported wire format. The error intentionally excludes the raw
// caller-controlled value.
var ErrInvalid = errors.New("invalid agent product")
var valuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// Parse normalizes and validates a caller-provided Agent product. Only
// surrounding ASCII spaces and tabs are trimmed; other control or Unicode
// whitespace remains visible to validation and is rejected. An unset or
// ASCII-whitespace-only value means "use the edition default".
func Parse(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", ErrInvalid
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > MaxValueBytes {
return "", ErrInvalid
}
if !valuePattern.MatchString(value) {
return "", ErrInvalid
}
return value, nil
}
// ResolveFromEnv returns the validated DWS_AGENT_PRODUCT value, or fallback
// when the environment variable is unset or empty. Invalid caller input is
// returned as an error so command entrypoints can fail before network access.
func ResolveFromEnv(fallback string) (string, error) {
value, err := Parse(os.Getenv(EnvName))
if err != nil {
return "", err
}
if value == "" {
return fallback, nil
}
return value, nil
}
+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 agentproduct
import (
"errors"
"strings"
"testing"
)
func TestParse(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "qwenwork", raw: "qwenwork", want: "qwenwork"},
{name: "legacy open claw", raw: "openClaw", want: "openClaw"},
{name: "trim", raw: " \tqwenwork\t ", want: "qwenwork"},
{name: "generic", raw: "agent-2_alpha", want: "agent-2_alpha"},
{name: "leading digit", raw: "2nd_product", want: "2nd_product"},
{name: "maximum length", raw: strings.Repeat("a", MaxValueBytes), want: strings.Repeat("a", MaxValueBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got != tc.want {
t.Fatalf("Parse() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork\r"},
{name: "line feed", raw: "\nqwenwork"},
{name: "internal space", raw: "qwen work"},
{name: "internal tab", raw: "qwen\twork"},
{name: "unicode", raw: "千问办公"},
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vqwenwork"},
{name: "form feed", raw: "qwenwork\f"},
{name: "next line", raw: "qwenwork\u0085"},
{name: "non-breaking space", raw: "\u00a0qwenwork"},
{name: "ideographic space", raw: "qwenwork\u3000"},
{name: "too long", raw: strings.Repeat("a", MaxValueBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.raw)
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse(%q) = %q, %v; want ErrInvalid", tc.raw, got, err)
}
if strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
func TestResolveFromEnv(t *testing.T) {
t.Run("unset uses fallback", func(t *testing.T) {
t.Setenv(EnvName, "")
got, err := ResolveFromEnv("openClaw")
if err != nil || got != "openClaw" {
t.Fatalf("ResolveFromEnv() = %q, %v; want openClaw, nil", got, err)
}
})
t.Run("valid environment wins", func(t *testing.T) {
t.Setenv(EnvName, " qwenwork ")
got, err := ResolveFromEnv("openClaw")
if err != nil || got != "qwenwork" {
t.Fatalf("ResolveFromEnv() = %q, %v; want qwenwork, nil", got, err)
}
})
t.Run("invalid environment does not return fallback", func(t *testing.T) {
t.Setenv(EnvName, "qwen work")
got, err := ResolveFromEnv("openClaw")
if got != "" || !errors.Is(err, ErrInvalid) {
t.Fatalf("ResolveFromEnv() = %q, %v; want empty, ErrInvalid", got, err)
}
})
}
+8 -10
View File
@@ -15,20 +15,18 @@ package edition
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
// DefaultOSSClawType is the wire value for request header claw-type in
// the open-source build. It is intentionally hard-wired — the open-source
// CLI does NOT derive claw-type from DINGTALK_AGENT or any other caller
// input, so third-party hosts get a predictable header regardless of
// their environment.
// DefaultOSSClawType is the default wire value for request header claw-type
// in the open-source build. DWS_AGENT_PRODUCT may explicitly override the
// request identity; unrelated caller inputs such as DINGTALK_AGENT do not.
const DefaultOSSClawType = "openClaw"
// defaultHooks returns the open-source edition defaults.
//
// MergeHeaders is the only hook that ships with behaviour: it pins the
// `claw-type` request header to DefaultOSSClawType so every open-source
// MCP request carries the same stable routing tag. All other fields are
// nil — the internal code interprets nil as "use standard open-source
// behaviour".
// MergeHeaders is the only hook that ships with behaviour: it supplies
// DefaultOSSClawType so every open-source MCP request has a stable default.
// The core applies an optional DWS_AGENT_PRODUCT override after edition hooks.
// All other fields are nil — the internal code interprets nil as "use
// standard open-source behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
+16 -7
View File
@@ -85,10 +85,12 @@ type Hooks struct {
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// ClawTypeValue is the claw identity carried in message-send tool
// ClawTypeValue is the display identity carried only in message-send tool
// arguments (parameter clawType) so the IM server can render the
// "Send from AI" indicator on delivered messages. Empty → falls back
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
// "Send from AI" indicator on delivered messages. It is intentionally
// separate from the HTTP claw-type Agent Product header and is not
// overridden by DWS_AGENT_PRODUCT. Empty → DefaultOSSClawType; overlays
// set their own message-display value (e.g. "wukong").
ClawTypeValue string
// PersonalEventSourceID identifies the personal-event source channel
@@ -106,9 +108,15 @@ type Hooks struct {
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
// MergeHeaders must preserve base headers. If it sets claw-type, that value
// must be deterministic and independent of the supplied base map because
// PAT error serialization resolves it with an empty map. The hook must not
// perform network, keychain, credential-refresh, or other blocking work.
MergeHeaders func(base map[string]string) map[string]string
// --- EnterpriseCredential HTTP headers ---
// This hook is only for credential material. It must not set claw-type;
// the core reasserts that Header after the hook returns.
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth ---
@@ -198,10 +206,11 @@ func Override(h *Hooks) {
current = h
}
// ClawType returns the claw identity for the active edition, falling back
// to DefaultOSSClawType when the overlay does not set one. Message-send
// helpers attach this value as the clawType tool argument so the IM server
// can label delivered messages as sent via AI.
// ClawType returns the message-display identity for the active edition,
// falling back to DefaultOSSClawType when the overlay does not set one.
// Message-send helpers attach this value as the clawType tool argument so the
// IM server can label delivered messages as sent via AI. It is a separate axis
// from the HTTP claw-type header and is not affected by DWS_AGENT_PRODUCT.
func ClawType() string {
if v := Get().ClawTypeValue; v != "" {
return v
+8 -6
View File
@@ -25,19 +25,19 @@ MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md"
SERVICE_TO_SKILL = {
"aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md",
"attendance": ROOT / "skills" / "multi" / "dingtalk-attendance" / "SKILL.md",
"attendance": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "attendance.md",
"calendar": ROOT / "skills" / "multi" / "dingtalk-calendar" / "SKILL.md",
"chat": ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
"contact": ROOT / "skills" / "multi" / "dingtalk-contact" / "SKILL.md",
"devapp": ROOT / "skills" / "multi" / "dingtalk-dev" / "SKILL.md",
"ding": ROOT / "skills" / "multi" / "dingtalk-ding" / "SKILL.md",
"ding": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "ding.md",
"doc": ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
"drive": ROOT / "skills" / "multi" / "dingtalk-drive" / "SKILL.md",
"mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md",
"minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md",
"oa": ROOT / "skills" / "multi" / "dingtalk-oa" / "SKILL.md",
"report": ROOT / "skills" / "multi" / "dingtalk-report" / "SKILL.md",
"sheet": ROOT / "skills" / "multi" / "dingtalk-sheet" / "SKILL.md",
"oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md",
"report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md",
"sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md",
"todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md",
"wiki": ROOT / "skills" / "multi" / "dingtalk-wiki" / "SKILL.md",
}
@@ -88,7 +88,9 @@ def mono_overview(items: list[dict[str, Any]]) -> str:
rows = []
for service, count in sorted(counts.items()):
path = SERVICE_TO_SKILL.get(service)
skill = path.parent.name if path else "—"
skill = "—"
if path:
skill = next((part for part in path.parts if part.startswith("dingtalk-")), path.parent.name)
rows.append(f"| `{md_escape(service)}` | {count} | `{md_escape(skill)}` | `dws shortcut list --service {md_escape(service)} --format json` |")
body = "\n".join(rows)
return f"""{MONO_START}
+5 -5
View File
@@ -44,19 +44,19 @@ cli_version: ">=1.0.15"
| 服务 | shortcut 数 | multi skill | 发现命令 |
|---|---:|---|---|
| `aitable` | 29 | `dingtalk-aitable` | `dws shortcut list --service aitable --format json` |
| `attendance` | 19 | `dingtalk-attendance` | `dws shortcut list --service attendance --format json` |
| `attendance` | 19 | `dingtalk-misc` | `dws shortcut list --service attendance --format json` |
| `calendar` | 20 | `dingtalk-calendar` | `dws shortcut list --service calendar --format json` |
| `chat` | 42 | `dingtalk-chat` | `dws shortcut list --service chat --format json` |
| `contact` | 14 | `dingtalk-contact` | `dws shortcut list --service contact --format json` |
| `devapp` | 19 | `dingtalk-dev` | `dws shortcut list --service devapp --format json` |
| `ding` | 4 | `dingtalk-ding` | `dws shortcut list --service ding --format json` |
| `ding` | 4 | `dingtalk-misc` | `dws shortcut list --service ding --format json` |
| `doc` | 17 | `dingtalk-doc` | `dws shortcut list --service doc --format json` |
| `drive` | 7 | `dingtalk-drive` | `dws shortcut list --service drive --format json` |
| `mail` | 10 | `dingtalk-mail` | `dws shortcut list --service mail --format json` |
| `minutes` | 6 | `dingtalk-minutes` | `dws shortcut list --service minutes --format json` |
| `oa` | 7 | `dingtalk-oa` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-report` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-sheet` | `dws shortcut list --service sheet --format json` |
| `oa` | 7 | `dingtalk-misc` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-misc` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-misc` | `dws shortcut list --service sheet --format json` |
| `todo` | 11 | `dingtalk-todo` | `dws shortcut list --service todo --format json` |
| `wiki` | 1 | `dingtalk-wiki` | `dws shortcut list --service wiki --format json` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
+18 -1
View File
@@ -1060,6 +1060,22 @@ Flags:
--background-id string 背景 ID (必填)
```
#### 原地更新消息的文字表情回应
```
Usage:
dws chat message update-text-emotion [flags]
Example:
dws chat message update-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --old-emotion-id <oldEmotionId> --emotion-id <emotionId> --emotion-name "处理中" --text "处理中 2 分钟" --background-id im_bg_5
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMsgId (必填)
--old-emotion-id string 待替换的原表情 ID (必填)
--emotion-id string 新表情 ID (必填,通过 create-text-emotion 获取)
--emotion-name string 新表情名称 (必填)
--text string 新文字内容 (必填)
--background-id string 新背景 ID (必填)
```
#### 移除消息的文字表情回应
```
Usage:
@@ -1911,6 +1927,7 @@ Flags:
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
用户说"修改文字表情回应/更新消息状态文字" → `chat message update-text-emotion`
用户说"取消文字表情回应/移除文字表情" → `chat message remove-text-emotion`
用户说"创建文字表情/新建文字表情" → `chat message create-text-emotion`
用户说"免打扰/消息免打扰/静音/开启免打扰/关闭免打扰" → `chat mute`
@@ -1973,7 +1990,7 @@ Flags:
- `chat message list-by-ids` — 根据消息 ID 批量查询消息(最多 50 条)
- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
- `chat message add-text-emotion` / `remove-text-emotion` — 对消息添加/移除文字表情回应
- `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
- `chat category list` — 获取用户自定义会话分组列表
- `chat category list-conversations` — 拉取指定分组下的会话列表
+29 -6
View File
@@ -34,9 +34,9 @@ ALLOWED_FIELD_TYPES = {
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink',
'bidirectionalLink', 'creator', 'lastModifier', 'createdTime',
'lastModifiedTime',
'barcode', 'geolocation', 'address', 'primaryDoc', 'formula',
'unidirectionalLink', 'bidirectionalLink', 'lookup', 'filterUp',
'creator', 'lastModifier', 'createdTime', 'lastModifiedTime',
}
FIELD_TYPE_ALIASES = {
'phone': 'telephone',
@@ -121,9 +121,32 @@ def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
)
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
linked_sheet_id = (config or {}).get('linkedSheetId')
if not linked_sheet_id or not validate_resource_id(linked_sheet_id):
return False, '关联字段必须提供合法的 config.linkedSheetId'
linked_table_id = (config or {}).get('linkedTableId')
if not linked_table_id or not validate_resource_id(linked_table_id):
return False, (
'关联字段必须提供合法的 config.linkedTableId(目标 Table ID)'
)
if field_type == 'lookup':
cfg = config or {}
if not cfg.get('associateField'):
return False, 'lookup 必须提供 config.associateField(本表关联字段的 fieldId)'
if not cfg.get('valuesField'):
return False, 'lookup 必须提供 config.valuesField(关联目标表中要取值的字段 fieldId)'
if not cfg.get('aggregator'):
return False, 'lookup 必须提供 config.aggregator(SUM/AVERAGE/COUNT/MAX/MIN/CONCATENATE)'
if field_type == 'filterUp':
cfg = config or {}
if not cfg.get('targetSheet'):
return False, 'filterUp 必须提供 config.targetSheet(目标 Table ID)'
filters = cfg.get('filters')
if not filters or not isinstance(filters, list):
return False, 'filterUp 必须提供 config.filters(至少一条筛选规则)'
if not cfg.get('valuesField'):
return False, 'filterUp 必须提供 config.valuesField(目标表中要取值的字段 fieldId)'
if not cfg.get('aggregator'):
return False, 'filterUp 必须提供 config.aggregator(SUM/AVERAGE/COUNT/MAX/MIN/CONCATENATE)'
return True, ''
-43
View File
@@ -1,43 +0,0 @@
---
name: dingtalk-agoal
description: 钉钉 Agoal 目标管理。Use when 用户说 目标管理/Agoal/战略解码/经营合约/计分卡/OKR/目标模板/周月报提交统计/跟催。Distinct from dingtalk-todo(待办任务)、dingtalk-report(日志)。命令前缀:dws agoal。
cli_version: ">=0.2.14"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉 Agoal Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[agoal.md](references/agoal.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "查战略解码 / OGSM" | `dws agoal strategy list --scope-type <DEPT|PERSONAL> --scope-id <ID>` |
| "看战略解码详情" | `dws agoal strategy detail --profile-id <PROFILE_ID>` |
| "更新战略解码" | 先 detail,基于完整数据修改后 `dws agoal strategy update ...` |
| "查经营合约 / KPI合约" | `dws agoal contract list/detail/fields` |
| "更新经营合约" | 先 detail,基于完整 dimensions 修改后 `dws agoal contract update ...` |
| "查计分卡" | `dws agoal scorecard detail --selected-time <ISO> --dept-id <DEPT_ID>` |
| "我的目标 / 个人目标" | `dws agoal user rules` → `dws agoal user objectives` |
| "周月报提交统计 / 跟催 / 迟交 / 未提交" | `dws agoal report list-statistics` → `dws agoal report submit-detail` |
| "目标模板" | `dws agoal obj-template list/create-or-update` |
## 写操作硬约束
- `strategy update` / `contract update` / `scorecard update` / `obj-template create-or-update` 都是覆盖式写入。
- 必须先读取现有详情或模板列表,在原数据基础上修改;禁止只传局部字段覆盖。
- 执行前必须向用户展示目标对象、修改字段和最终命令参数摘要,并等待确认。
@@ -1,77 +0,0 @@
# Agoal(目标管理)
## 产品说明
Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分卡、用户目标、目标模板、周月报六大模块,帮助组织将战略目标从顶层分解到个人并持续跟踪。
**CLI 前缀**: `dws agoal`
## 命令总览
### strategy (战略解码管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `strategy list` | 获取战略解码列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
| `strategy detail` | 获取战略解码详情 | `--profile-id` | 根据战略解码 id 查询 |
| `strategy update` | 更新战略解码 | `--profile-id` `--content` | 覆盖逻辑,必须基于查询返回的老数据修改后再传入;`--content` 为 JSON 数组 |
### contract (经营合约管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `contract list` | 获取经营合约列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
| `contract fields` | 获取经营合约字段列表 | - | 获取组织下经营合约的字段配置 |
| `contract detail` | 获取经营合约详情 | `--contract-id` | 根据合约 id 查询 |
| `contract update` | 更新经营合约 | `--contract-id` `--dimensions` | 覆盖逻辑;可选 `--audit-config`、`--objective-template` |
### scorecard (计分卡管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串 |
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
### user / report / obj-template
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `user rules` | 获取用户规则周期列表 | - |
| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` |
| `report list-statistics` | 获取周月报数据跟催列表 | - |
| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` |
| `obj-template list` | 获取目标模板列表 | - |
| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` |
## 意图判断
- "战略解码/战略目标/OGSM" → `strategy list/detail/update`
- "经营合约/合约/KPI合约" → `contract list/fields/detail/update`
- "计分卡/scorecard/绩效看板" → `scorecard detail/entity-detail/update`
- "目标/OKR/我的目标/个人目标" → `user rules/objectives`
- "目标模板/模板管理" → `obj-template list/create-or-update`
- "周月报/周报统计/提交情况/跟催/迟交/未提交" → `report list-statistics/submit-detail`
## 常用命令
```bash
dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID --format json
dws agoal strategy detail --profile-id PROFILE_ID --format json
dws agoal contract list --scope-type PERSONAL --scope-id USER_ID --format json
dws agoal contract fields --format json
dws agoal contract detail --contract-id CONTRACT_ID --format json
dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --format json
dws agoal user rules --user-id USER_ID --format json
dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json
dws agoal report list-statistics --format json
dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME --format json
dws agoal obj-template list --keyword "业绩" --format json
```
## 安全规则
- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。
- 写入类命令执行前必须展示变更摘要并等待用户确认。
- `--selected-time` / `--query-date` 接受 ISO-8601 字符串。
- `--period-ids` 为逗号分隔字符串。
+49 -14
View File
@@ -1,25 +1,19 @@
---
name: dingtalk-aisearch
description: AI 搜问 - 搜人首选入口(按姓名/部门/职位/职责/上下级/手机号/工号维度)。Use when 用户说 找同事/找人/谁负责XX/XX的负责人是谁/查上级/查下级/团队成员/XX工号是谁/XX手机号。Distinct from dingtalk-contact(精确按 userId 查详情)。命令前缀:dws aisearch。
cli_version: ">=0.2.14"
description: AI搜问:人员语义搜索与跨源定位。Use when 按姓名/工号/部门/职责/上下级或手机号线索找人,跨文档/消息/邮件/听记检索,或回溯“我发过/收到过”。完整手机号反查走 dingtalk-contact;找到 userId 后由 contact 补详情。命令前缀:dws aisearch。
metadata:
cli_version: ">=0.2.14"
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉 AI 搜问(搜人)Skill
# 钉钉 AI 搜问 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 前置条件 — 执行操作前必读
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[aisearch.md](references/aisearch.md)。
@@ -31,9 +25,47 @@ metadata:
| "谁负责 XX / XX 负责人是谁" | `dws aisearch person --keyword "<XX>" --dimension duty` |
| "张三的上级 / 下级" | `dws aisearch person --keyword "张三" --dimension supervisor`(或 `subordinate`) |
| "X 部门有哪些人" | `dws aisearch person --keyword "<部门>" --dimension department` |
| "工号 12345 是谁 / 138xxxx 手机号是谁" | `dws aisearch person --keyword "<工号>" --dimension jobNumber` / `dws aisearch person --keyword "<手机号>" --dimension phone` |
| "工号 12345 是谁" | `dws aisearch person --keyword "<工号>" --dimension jobNumber` |
| "按手机号线索语义搜人" | `dws aisearch person --keyword "<手机号线索>" --dimension phone` |
| "完整手机号精确反查" | `dws contact user search-mobile --mobile "<完整手机号>"` |
| "最近 OKR 相关邮件 / 项目相关文档" | `dws aisearch enterprise --queries "<主题>" --types mail/document --time-range "<时间>"` |
| "我发过/创建过/分享过/收到过什么" | `dws aisearch behavior --queries "<主题>" --behavior-type <动作> --direction <方向>` |
## 评测高频硬约束
## 标准 SOP(必遵流程)
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 flag/ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段,不得凭返回结构猜测。
### SOP-1 搜人 → 拿 userId(search-person)
**触发**:姓名模糊找人/谁负责/查上下级/部门成员/工号反查/手机号线索语义搜人。
1. **定维度(必须)**:姓名→`name`、"谁负责 XX"→`duty`、部门成员→`department`、上级/下级→`supervisor`/`subordinate`、工号→`jobNumber`、手机号语义线索→`phone`;完整手机号精确反查切到 `dingtalk-contact` 的 `user search-mobile`;不确定→`all`。`--keyword` 必须按用户原文**完整保真**,切勿截断、改昵称、扩同音字。
2. **执行(必须)**:`dws aisearch person --keyword "<完整值>" --dimension <维度> --format json`。
3. **解析(必须)**:从 JSON 取 `userId` / `openDingTalkId`;**多候选必须输出让用户选,禁止默认取第一个、禁止编造**未返回的人员字段。
4. **衔接(必须)**:要邮箱/部门/职位/主管等详情 → 切 `dingtalk-contact` 执行 `dws contact user get --ids <userId> --format json`;发消息 → `dingtalk-chat`;发 DING → `dingtalk-misc`(`references/ding.md`)。
5. **失败(必须)**:未命中最多换 1 个维度重试一次(如 `name`→`department`/`jobNumber`/`phone`),仍保留完整目标值;仍无果**必须如实告知**。
**禁止**:用半截姓名扩大搜索、跳过 `--format json`、取首个候选、凭空补全人员信息。
### SOP-2 跨源搜内容(search-content)
**触发**:跨文档/邮件/消息按主题找内容。
1. **执行(必须)**:`dws aisearch enterprise --queries "<主题>" --types <document,mail,...> --time-range "<时间>" --format json`;多主题逗号分隔。
2. **衔接(必须)**:按命中来源切到对应产品 skill 读写。**aisearch 只负责"找到",不做读写。**
**禁止**:把 aisearch 当作读写入口、跳过下游 skill 直接改数据。
### SOP-3 行为回溯(search-behavior)
**触发**:"我发过/收到过/创建过/分享过什么"。
1. **执行(必须)**:`dws aisearch behavior --queries "<主题>" --behavior-type <动作> --direction <方向> --format json`。
2. **衔接(必须)**:按记录类型切对应 skill 操作;aisearch 不做读写。
**禁止**:编造行为结果、跳过 `--format json`。
## 高频硬约束
- 搜索目标必须完整保真:姓名、工号、手机号、部门名按用户原文完整传入 `--keyword`,严禁自行截断、拆字、改昵称或扩展同音字。
- 首次未命中时最多换维度重试一次(如 name → department/jobNumber/phone),仍必须保留完整目标值;不要用半截姓名扩大搜索。
@@ -45,4 +77,7 @@ metadata:
- 拿到 userId 后查详情 / 部门 → 切到 `dingtalk-contact`
- 拿到 userId 发消息 → 切到 `dingtalk-chat`
- 拿到 userId 发 DING → 切到 `dingtalk-ding`
- 拿到 userId 发 DING → 切到 `dingtalk-misc`(`references/ding.md`)
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
@@ -19,7 +19,7 @@ Example:
dws aisearch person --keyword "五道" --dimension supervisor --format json
dws aisearch person --keyword "AI搜问" --dimension duty --format json
dws aisearch person --keyword "李四" --dimension name,department --format json
dws aisearch person --keyword "13800138000" --dimension phone --format json
dws aisearch person --keyword "<手机号线索>" --dimension phone --format json
dws aisearch person --keyword "W12345" --dimension jobNumber --format json
Flags:
--keyword string 搜索关键词 (必填,如人名、技能关键词等)
@@ -37,7 +37,7 @@ Flags:
| `duty` | 职责/技能 | "负责什么"、"职责"、"技能"、"负责人" |
| `supervisor` | 上级 | "上级"、"领导"、"主管" |
| `subordinate` | 下级 | "下级"、"下属"、"团队成员" |
| `phone` | 手机号 | "手机号是多少"、"电话"、"联系方式" |
| `phone` | 手机号语义线索 | "电话线索"、"联系方式相关";完整手机号精确反查走 `contact user search-mobile` |
| `jobNumber` | 工号 | "工号"、"工号是多少"、"员工编号" |
### keyword 提取规则
@@ -51,7 +51,7 @@ Flags:
| "AI搜问的负责人是谁" | AI搜问 | duty |
| "产品部有谁" | 产品部 | department |
| "李四是哪个部门的" | 李四 | department |
| "13800138000是谁" | 13800138000 | phone |
| "按手机号线索找人" | 用户提供的手机号线索 | phone |
| "工号W12345是谁" | W12345 | jobNumber |
---
@@ -59,12 +59,13 @@ Flags:
## 意图判断
- 用户说"搜人/找人/谁负责/上级是谁/哪个部门的人" → `aisearch person`
- 用户提供完整手机号精确反查 → `contact user search-mobile`
- 用户说"搜资料/找方案/查文档/搜企业知识/项目相关内容/工作总结/周报总结" → `aisearch enterprise`
- 用户说"最近/本周/今天 + XX相关消息/文档/邮件有哪些" → `aisearch enterprise`,时间词进 `--time-range`,类型词进 `--types`
- 用户说"我发过/谁发给我/创建过/分享过/收到过/今天我干了什么" → `aisearch behavior`
- 用户说"搜同事/查部门/查通讯录" → `contact`(通讯录)
- 用户说"搜同事" → `aisearch person`;查部门详情/部门成员/通讯录精确信息 → `contact`
**关键区分**:`aisearch person`(AI 语义搜人,支持职责/手机号/上级/下级等维度)vs `aisearch enterprise`(按内容找企业内部知识)vs `aisearch behavior`(按动作找发送/创建/分享/编辑/接收记录)vs `contact`(通讯录精确查询:userId/部门成员列表)
**关键区分**:`aisearch person`(姓名模糊、工号、职责、手机号线索、上下级等搜索)vs `aisearch enterprise`(按内容找企业内部知识)vs `aisearch behavior`(按动作找发送/创建/分享/编辑/接收记录)vs `contact`(完整手机号反查、已知 userId 详情、部门成员列表)
### 高优先级抽取规则
@@ -98,7 +99,7 @@ Flags:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) |
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
## 重名消歧
@@ -0,0 +1,15 @@
# aisearch 局部意图消歧
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "张三在哪个部门/张三的工号是多少" | 搜人后查通讯录详情 | `aisearch person` → `contact user get` | 直接 `contact user search` | 姓名或工号先由 aisearch 获取 userId,再由 contact 补部门、工号等详情 |
| "找一下张三/搜同事/找人" | 人员语义搜索 | `aisearch person` | `contact user search` | 姓名模糊搜索、工号、部门、职责和上下级走 aisearch;contact 在拿到 userId 后补详情 |
| "五道的上级是谁/谁负责XX/XX的下属有谁" | AI语义搜人 | `aisearch person` | `contact` | 涉及上下级、职责、负责人等语义维度搜索,用 aisearch |
| "222020这个工号是谁/查工号" | 按工号搜人 | `aisearch person --dimension jobNumber` | `contact` | 工号查人走 aisearch,dimension=jobNumber |
| "13800138000是谁/完整手机号反查" | 精确手机号反查 | `contact user search-mobile` | `contact user search` | 完整手机号精确匹配使用 search-mobile |
| "按手机号线索找人" | 手机号语义搜人 | `aisearch person --dimension phone` | `contact user search` | 非精确手机号匹配走 aisearch 的 phone 维度 |
| "搜一下智能化方案/最近 OKR 相关邮件/最近发版相关消息" | 搜企业知识内容 | `aisearch enterprise` | `doc search` / `mail search` / `chat message search` | 跨文档、消息、日程、听记、邮件等企业内容语义检索走 enterprise;具体 `queries/types/time-range` 抽槽见 `aisearch.md` |
| "我发给某人的消息/邮件/文档/今天我干了什么" | 搜行为记录 | `aisearch behavior` | `chat` / `mail` / `doc` / `report` | 关注“谁对什么做过什么”,走 behavior;具体 `behavior-type/direction/chat-scope` 抽槽见 `aisearch.md` |
| "把这段文字翻译成英文/translate this" | 通用文本翻译 | `chat text translate` | `doc` / `aisearch` | 纯文本翻译,不是文档编辑或语义搜索 |
@@ -0,0 +1,31 @@
# aisearch Lite Recipe
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
## #8 通讯录
### get-contact-self
`contact user get-self` → 当前用户 userId、部门、主管等
### search-person
**搜人首选入口**。凡是“找人/搜人/找同事/谁负责/上级/下级/负责人/团队成员”均优先用 `aisearch person`:
1. 从用户问题中提取 keyword(人名/业务关键词)和 dimension(维度),规则见 [aisearch.md](./aisearch.md)。
2. `aisearch person --keyword "<关键词>" --dimension <维度>`
3. 结果中提取 `userId` 和 `title`(姓名)展示给用户。
4. 若需要 userId 做后续操作(发消息/建待办),可直接使用结果中的 `userId`。
5. **重名消歧**:多人同名时禁止默认选第一个,须追加 `contact user get --ids` 获取部门/职位后请用户确认,详见 [08-directory.md](../../dingtalk-contact/references/08-directory.md)「多命中」。
### search-user
仅在以下**精确查询**场景使用,搜人请优先用 `search-person`:
- 需要获取 userId 给其他产品使用(发消息/建待办/约日程)
- 已有 userId 需查完整详情(`contact user get --ids`)
- 完整手机号精确反查(`contact user search-mobile --mobile`)
1. 完整手机号精确反查:`contact user search-mobile --mobile "<手机号>"`;其他搜人:`aisearch person --keyword "<关键词>" --dimension <维度>`。
2. **重名消歧**:多人同名时禁止默认选第一个,须追加 `contact user get --ids` 获取部门/职位后请用户确认,详见 [08-directory.md](../../dingtalk-contact/references/08-directory.md)「多命中」。
3. 需详情时:`contact user get --ids <userId>`(多人可 `--ids id1,id2,...`)。
+72 -12
View File
@@ -1,10 +1,9 @@
---
name: dingtalk-aitable
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。Distinct from 主 dws skill 的 dws sheet(电子表格/单元格读写/公式)、dws doc(文档编辑)。命令前缀:dws aitable。
cli_version: ">=0.2.14"
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。
metadata:
cli_version: ">=0.2.14"
category: product
stability: experimental
requires:
bins:
- dws
@@ -12,14 +11,9 @@ metadata:
# 钉钉 AI 表格 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 前置条件 — 执行操作前必读
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[aitable.md](references/aitable.md);复杂命令按需加载 `references/aitable/*.md`;剧本:[06-data-analytics.md](references/06-data-analytics.md)。
@@ -78,11 +72,74 @@ metadata:
| "仪表盘 / 图表" | 先读 `references/aitable/aitable-dashboard-chart.md` |
| "上传附件到记录" | 先读 `references/aitable/aitable-attachment.md`;可用 `python scripts/upload_attachment.py --base-id <id> --file <path>` |
## 标准 SOP(必遵流程)
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 flag/ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段,不得凭返回结构猜测。`baseId`/`tableId`/`fieldId`/`recordId` 一律先查后用,**禁止默认/编造**。
### SOP-1 定位 Base 与 Table(list / search → table get)
**触发**:找/打开某张 AI 表格、不知 baseId 或 tableId。
1. **选源(必须)**:有名称/关键词 → `dws aitable base search --query "<名称>"`;列最近访问 → `dws aitable base list`。`base list` 仅返回最近访问,不是全部,**禁止**当作全量清单。
2. **执行(必须)**:`dws aitable base search --query "<完整名>" --format json`(或 `dws aitable base list --format json`)。
3. **解析(必须)**:从 JSON 取真实 `baseId`;**多候选必须输出让用户选,禁止默认取第一个**。
4. **取 tableId(必须)**:`dws aitable table get --base-id <baseId> --format json` → 从 `data.tables[].tableId` 取目标表 ID,并记录 `views[]`。枚举模式不返回 `fields[]`;需要字段目录时必须继续执行 SOP-2 的 `field get`。若只核对某张表,可显式加 `--table-ids <tableId>` 控制返回体。
5. **失败(必须)**:`base list` 为空或不命中 → 换 `base search --query` 关键词重试一次;仍无果**必须如实告知**,禁止臆造 baseId/tableId。
**禁止**:跳过 `table get` 直接用字段名写记录、用模糊名匹配当 baseId、用旧会话里的 ID 不再校验。
### SOP-2 拿字段定义(field get,写记录/改字段前置)
**触发**:建/改/写记录、改字段名或 options、按字段类型拼写入参前。
1. **前置(必须)**:先按 SOP-1 拿到 `baseId` + `tableId`。
2. **执行(必须)**:`dws aitable field get --base-id <baseId> --table-id <tableId> --format json`(仅展开需要的字段时加 `--field-ids fld1,fld2`,单次最多 10 个)。
3. **解析(必须)**:取每个目标字段的 `fieldId`、`type`、`config`(如 singleSelect/multipleSelect 的 `options[].id|name`);写入 cells 的 key **必须用 `fieldId`**,不是字段中文名;select 字段过滤/写入传**选项名称字面量**,不传 option ID。
4. **衔接(必须)**:拿到字段定义 → 进入 SOP-3 写记录、或 `dws aitable field update --field-id <fieldId> --name <新名>|--config <JSON> --format json` 改字段。
5. **失败(必须)**:字段不存在或类型不符 → 重新 `field get` 核对,**禁止**凭旧名称/旧类型继续写入。
**禁止**:用字段中文名当 cells key、跳过 `field get` 直接 `record create/update`、对 select 字段传 option ID 当写入值。
### SOP-3 写/批量写记录(record create)
**触发**:新增记录、批量加数据、CSV/JSON 入表。
1. **前置(必须)**:SOP-1 取 `baseId`/`tableId` + SOP-2 取 `fieldId`/类型。
2. **执行(必须)**:`dws aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":<值>}}]' --format json`;单次最多 100 条,超长用 `--records-file ./data.json`。
3. **写入格式(必须)**:按 `record create --help` 类型表严格传值(text→字符串、number→数值、singleSelect→"选项名"、date→RFC3339、url→`{"text","link"}`、group→`{"cid"}` 等);`filterUp`/`lookup` 字段只读不可写。
4. **解析与验证(必须)**:从返回 `data.newRecordIds[]` 取全部新记录 ID;不要读取不存在的标量 `recordId`。立即执行 `dws aitable record query --base-id <baseId> --table-id <tableId> --record-ids <id1,id2,...> --format json` 回读写入值。
5. **失败(必须)**:类型/格式错误按返回报错修正后重试,**禁止**降级丢弃字段;不确定格式先 `field get` 复核 config。
**禁止**:编造 fieldId/recordId、跳过 `field get` 凭中文名写、把 URL 字符串直接塞给 url 字段。
### SOP-4 查/筛/排记录(record query)
**触发**:查记录、按条件筛选、排序、取关联记录、定位待改/待删的 recordId。
1. **前置(必须)**:SOP-1 拿 `baseId`/`tableId`。
2. **执行(必须)**:`dws aitable record query --base-id <baseId> --table-id <tableId> --format json`;已知 ID 直取加 `--record-ids rec1,rec2`(忽略 filters/sort,单次≤100)。
3. **筛选/排序(必须)**:`--filters` 最外层必须 `{"operator":"and|or","operands":[...]}`,select 字段值传**选项名字面量**;日期只能用 `date_eq/before/after/not_before/not_after`,范围用 `not_before`+`not_after` 组合,**禁止** `eq`/区间/相对时间。`--sort` 用 `[{"fieldId":"..","direction":"asc|desc"}]`(**必须用 `direction`**)。公式/引用/关联字段默认不返回,需显式 `--field-ids` 指定。
4. **解析(必须)**:取真实 `recordId` 与字段值;分页用 `--cursor`,全表用 `--all --page-limit N`。
5. **衔接(必须)**:拿到 recordId → SOP-5 更新、`record delete --record-ids --yes` 删除(删前确认)。
**禁止**:用字段名做 filter/sort key、对日期用 `eq`、漏掉 `direction` 用旧 `order` 字段、用本地过滤替代服务端 filter。
### SOP-5 更新记录(record update)
**触发**:改记录字段值、批量更新状态、单字段重命名需求之外的记录改动。
1. **前置(必须)**:SOP-1 拿 `baseId`/`tableId`;SOP-2 拿字段类型;SOP-4 拿目标 `recordId`。
2. **执行(必须)**:`dws aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"recXXX","cells":{"<fieldId>":<新值>}}]' --format json`(每条必含 `recordId`+`cells`,单次≤100;超长用 `--records-file`);只传需改字段,未传保持原值。
3. **解析与验证(必须)**:写入格式同 SOP-3;从返回 `data.recordIds[]` 取实际更新的记录 ID。更新响应不返回“受影响字段”,必须立即用 `record query --record-ids <id1,id2,...> --format json` 回读目标字段确认。
4. **失败(必须)**:recordId 不存在或类型不符 → 回 SOP-4 重新定位,**禁止**编造 ID 强写。
**禁止**:省略 `recordId`、用字段中文名当 cells key、凭空猜测 recordId 直接 update。
## 危险操作
`base delete` / `table delete` / `field delete` / `record delete` 不可逆,必须先向用户确认再加 `--yes`。
## 评测高频硬约束
## 高频硬约束
- 创建/改字段/写记录是多轮连续任务时,不能在"让我执行/先获取 ID"后停下;必须实际调用对应 `dws aitable` 命令并验证结果。
- 字段重命名使用 `dws aitable field update --base-id <baseId> --table-id <tableId> --field-id <fieldId> --name "<新名称>" --format json`;先 `field get` 找真实 `fieldId`,不要猜字段名能直接更新。
@@ -97,4 +154,7 @@ metadata:
## 跨产品协作
- 单元格 / 工作表 / 公式 → 走主 `dws` skill 的 `sheet` 产品路由(`dws sheet`)
- 单元格 / 工作表 / 公式 → 切到 `dingtalk-misc`(`references/sheet.md`,命令前缀:`dws sheet`)
## 局部意图
- [局部意图消歧](references/intent-guide.md)。
@@ -4,10 +4,8 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. **取记录(按场景分流)**:<br>  • 数据统计/分析/全量汇总 → `aitable record query --base-id <baseId> --table-id <tableId> --all`(自动翻页,**禁止凭单页数据做统计**)<br>  • 大表保险 → 加 `--page-limit 100`(默认 50 页/5000 条,0 = 无限制)<br>  • 单纯预览前几条 → `aitable record query --base-id <baseId> --table-id <tableId> --limit 30`(不加 --all)<br>  • 筛选时 `--filters` 格式见 [aitable-filter-sort.md](../products/aitable/aitable-filter-sort.md)<br>4. **检查输出契约**:`hasMore=true` 时数据被截断,必须用 `--cursor <X>` 续拉;`partial=true` 时表示中途某页失败(保留已拉数据,可重试)<br>5. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3(**必须用 --all 防漏数据**)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **写入路径分流**(关键决策):<br>  • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br>  • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>`(脚本内置 prepare→OSS PUT→import data 全流程和正确的头处理,**禁止自己写 PUT**)。注意 `aitable import upload` 没有 `--file` flag、也不代做 PUT,只准备导入;能一站式完成的是上面的脚本<br>  • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. **取目标 record**:<br>  • 已知少量 recordId → `aitable record query --record-ids <ID1,ID2>`<br>  • 按条件批量改 → `aitable record query --base-id <baseId> --table-id <tableId> --filters '<JSON>' --all`(**用 --all 防止漏改**)<br>3. **先展示让用户确认要改的 record 列表**<br>4. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'`(单次 ≤30 条) |
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. `aitable record query --base-id <baseId> --table-id <tableId>` → 取记录(分页)<br>  需要筛选时 `--filters` 格式见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md),根节点必须是 `{"operator":"and\|or","operands":[...]}`<br>4. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **批量导入优先**:`python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(自动分批创建)<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable record query --base-id <baseId> --table-id <tableId>` → 取 `recordId`,**先展示让用户确认**<br>3. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'` |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
| export-aitable-to-xlsx | 1. `aitable base search --query "<表格名>"` → 取 `baseId`<br>2. **按场景选 scope**:<br>  • 全表+附件 → `aitable export data --base-id <baseId> --scope all --export-format excel_and_attachment --output ./<name>.xlsx`<br>  • 单表(仅 xlsx)→ `--scope table --table-id <tableId> --export-format excel`<br>  • 单视图 → `--scope view --table-id <tableId> --view-id <viewId>`<br>3. CLI 内置渐进式退避轮询 + 自动落盘,**不要自己写 GET downloadUrl**<br>4. 大表超时(默认 5 分钟):加 `--timeout-sec 900` 或拿到 `taskId` 后 `aitable export data --task-id <ID> --output ./<name>.xlsx` 续等<br>5. 与悟空脚本路径并存:复杂场景(多 base 批量 / 按视图组合)请用 `python scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view` |
| primary-doc-from-record | 当 AI 表格用文档作主键字段时,从 record 跳到其主文档:<br>1. `aitable record query` 取 `recordId`<br>2. `aitable base get-primary-doc-id --base-id <baseId> --table-id <tableId> --record-id <recordId>` → 取主键文档 `dentryUuid`<br>3. 凭 `dentryUuid` 调 `doc read --node <UUID>` 拿文档内容 |
@@ -1,6 +1,6 @@
# 业务域通用规范
> 仅服务本仓库已迁入的文档与 AI 表格行动指南。安全门控、危险操作确认、`--format json` 等已在根 skill 中定义,此处不重复。
> 仅服务本 skill 已迁入的行动指南。安全门控、危险操作确认、`--format json` 等已在本 skill 的 `SKILL.md` 中定义,此处不重复。
## 批量查询规范
@@ -9,7 +9,8 @@
| 1 | **并行查详情**:拿到多个 ID 后,用 `&` 合并到同一条 Shell 命令并行执行 + `wait`,**严禁逐条串行** |
| 2 | **翻页**:分页接口须拉全直至无更多 |
| 3 | **优先批量 API**:有批量接口则用批量;无则按 #1 并行 |
| 4 | **列表少轮次**:带条件搜索/列表 → 一次采全详情;**禁止**无新参数时重复同一 `list` / `search` |
| 4 | **群消息**:必须先 `chat search --query` 得 `openConversationId`,再 `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction older`;多群同条命令并行 |
| 5 | **列表少轮次**:带条件搜索/列表 → 一次采全详情;**禁止**无新参数时重复同一 `list` / `search` |
## 多源并行采集(公共模式)
@@ -26,10 +27,18 @@
| 字段 | 来源 | 传递给 |
|------|------|--------|
| `nodeId` | `doc search` | `doc read/update/copy/move/rename --node` |
| `nodeId` | `doc list` 中的 folder 类型节点 / `doc folder create` | `doc list --folder`、`doc create --folder`、`doc upload --folder`、`doc copy/move --folder` |
| `taskUuid` | `minutes list` | `minutes get summary/info/batch --id(s)` |
| `userId` | `aisearch person` / `contact user search` / `contact dept list-members` | `contact user get --ids`、`todo --executors`、`calendar --users` |
| `deptId` | `contact dept search` | `contact dept list-members --ids <deptId1,deptId2...>`;多子部门时对每个子部门分别 `dept search` 取 id |
| `nodeId` | `drive search` / `wiki node search` | `doc read/update --node`、`drive copy/move/rename/delete --node` |
| `nodeId` | `wiki node list` 中的 folder 类型节点 / `wiki node create --type folder` | `wiki node list --folder`、`wiki node create --folder`、`drive upload --folder`、`drive copy/move --folder` |
| `eventId` | `calendar event list` | `calendar event get/update --id` |
| `processInstanceId` | `oa approval list-*` | `oa approval detail/approve --instance-id` |
| `openConversationId` | `chat search` | `chat message list/send --group` |
| `todoTaskId` | `todo task list` | `todo task update/done --task-id` |
| `reportId` | `report inbox list` / `report outbox list` | `report entry get/stats --report-id` |
| `baseId` / `tableId` | `aitable base search` | `aitable record query --base-id --table-id` |
| `dentryUuid` | `drive list` / `drive mkdir` | `drive info/download --file-id`、`drive list/mkdir/upload --parent-id` |
| `workspaceId` | `wiki space search/list/create` | `doc list/search/create --workspace`、`wiki member * --workspace` |
| `dentryUuid` | `drive list` / `drive mkdir` | `drive info/download/copy/move/rename/delete --node`、`drive list/mkdir/upload/copy/move --folder` |
| `dentryId` | `drive info` 的数字字段 | 仅用于 `chat message send --dentry-id` |
**ID 边界硬约束**:遇到 `drive --parent-id`、`doc --folder`、`doc --node` 时,只能使用 `dentryUuid` / `nodeId` / 文档 URL。若当前上下文只有数字型 `dentryId`,必须先重新 `drive list` / `doc list` / `doc search` 获取正确 ID,不能把该数字直接代入后续命令。
**ID 边界硬约束**:`dentryId` 通常是纯数字,只表示聊天文件消息需要的钉盘条目数字 ID;它不是父目录 ID。遇到 `drive --node/--folder`、`doc --node`、`wiki node --folder` 时,只能使用 `dentryUuid` / `nodeId` / 文档 URL。若当前上下文只有数字型 `dentryId`,必须先重新 `drive list` / `drive search` / `wiki node list` 获取正确 ID,不能把该数字直接代入后续命令。
@@ -38,7 +38,7 @@ dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --limit 50 --
## 添加记录
**必须先执行 `table get` 获取 fieldId,再写入。cells 的 key 必须是 fieldId(如 fldXXX),不是字段名。**
**必须先执行 `field get` 获取 fieldId,再写入。cells 的 key 必须是 fieldId(如 fldXXX),不是字段名。**
```bash
# 单条
@@ -59,6 +59,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
{"data": {"newRecordIds": ["rec-new-001", "rec-new-002"]}}
```
从 `data.newRecordIds[]` 提取新记录 ID,并立即执行 `record query --record-ids <id1,id2,...>` 回读写入结果;不要只看命令退出码。
### --records 格式常见错误
```bash
@@ -85,7 +87,7 @@ dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--format json
```
只需传入需修改的字段,未传入的保持原值。
更新响应中的成功记录 ID 位于 `data.recordIds[]`。只需传入需修改的字段,未传入的保持原值;响应不返回“受影响字段”,必须再执行 `record query --record-ids <id1,id2,...>` 回读确认。
## 删除记录
@@ -132,4 +134,4 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
- 公式字段、引用字段
- 自动编号字段
执行 `table get` 后识别字段类型,跳过只读字段。
执行 `field get` 后识别字段类型,跳过只读字段。
@@ -7,10 +7,16 @@
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
@@ -19,11 +25,9 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 按名称搜索 Base(别名 `aitable search`) | — | `--query` help 标必填但实际可省略:不传时返回最近访问的 Base 列表。`--keyword` 是 `--query` 的隐藏别名,同义 |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base copy` | 复制整个 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 默认全量复制;`--only-struct` 仅复制结构不含数据 |
| `base get-primary-doc-id` | 获取某记录的主键文档 ID | `--base-id` `--table-id` `--record-id` | 等价 `record primary-doc-get` 的取 ID 视角 |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
@@ -31,10 +35,9 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table list` | 获取数据表(`table get` 的别名) | `--base-id` | 与 `table get` 等价 |
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 为 JSON 数组;**可传空数组 `[]`**(默认值即 `[]`),此时服务端自动补一个名为"标题"的 primaryDoc 首列;单次最多 15 个字段 |
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
| `table get` | 获取数据表/视图目录 | `--base-id` | 不传 `--table-ids` 枚举全部表,但不返回字段;字段目录使用 `field get` |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
@@ -42,57 +45,83 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
| `field list` | 获取字段信息(`field get` 的别名) | `--base-id` `--table-id` | 与 `field get` 等价 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 单字段/批量两种模式严格互斥;单字段配置传 `--config`,批量配置写入 `--fields` 每个元素的 `config` |
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
| `field search-options` | 搜索单选/多选字段的选项 | `--base-id` `--table-id` `--field-id` | 仅 singleSelect/multipleSelect;`--keyword` 模糊过滤,不传返回全部 |
#### 搜索字段选项
```
Usage:
dws aitable field search-options [flags]
Example:
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
Flags:
--base-id string Base ID (必填)
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
--table-id string Table ID (必填)
```
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
适用场景:
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
|------|------|----------------|----------|
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference;`--query`(隐藏别名 `--keyword`)全文搜索 |
| `record list` | 获取记录(`record query` 的别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 与 `record query` 等价 |
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `field get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record query-empty` | 查询完全没填用户字段的空行 | — | `--base-id` `--table-id`;`--limit` 扫描预算 [1,100],`--cursor` 翻页 |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;**没有** `--record-id` `--cells` flag |
| `record batch-update` | 把同一份 cells 批量应用到多条记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | `--record-ids`(≤100)+ `--cells` 共享 patch |
| `record upsert` | 批量创建或更新(有 recordId 走更新,无则创建) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | `--records`/`--records-file`;单次最多 100 条 |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;`cells` key 支持 fieldId 或当前表内唯一字段名,推荐 fieldId;`--records` 是 `[{recordId,cells},...]` 数组 |
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | `--record-ids`(逗号分隔,单次最多 20 条) |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | `--record-id` 单条;`--offset`/`--limit`(≤50) |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 无文档时返回 `no record` 错误 |
| `record primary-doc-create` | 为记录创建主键文档 | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 幂等;`--field-id` 须 primaryDoc 类型 |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner |
| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;用 `--config` 传 visibleFieldIds/filter/sort/group;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
> **view update --config 支持的 key 白名单**(传入其他 key 会报错):
> - `visibleFieldIds` — 视图可见字段列表及顺序(首列字段必须保留在第一位)
> - `filter` — 筛选规则列表
> - `sort` — 排序规则列表
> - `group` — 分组规则列表
> - `fieldWidths` — 列宽映射(仅 Grid 视图有效)
>
> 不支持 `formInfo`、`requiredFields`、`conditionalRules` 等 FormDesigner 高级配置,这些 key 会被服务端忽略。
> **属性按 attr 分类,决定该读哪份子文档**:
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
> **`view update --config` 支持的 9 个 key**:
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 每条含 viewId/name;新建表单无 title 且 createdAt=0,改过后才有 |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | 客户端按 viewId 过滤,`data` 即该表单对象;viewId 不存在报 not found |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | 等价 `view create --view-type FormDesigner` |
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
@@ -105,14 +134,14 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
| `workflow create` | 创建并发布自动化工作流 | `--base-id` `--dsl` | 按子文档 Demo 组装 DSL;必须检查返回的 `data.valid` / `issues`;create 不自动重试 |
| `workflow update` | 更新并发布已有自动化工作流 | `--base-id` `--workflow-id` `--dsl` | 先 get 留底;提交完整目标 DSL;必须检查 `data.valid` / `issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> 当前支持创建、更新、查询和启停;删除、运行历史及手动触发仍未开放。
> 创建/更新的 `--dsl` 使用钉钉 AI 表格 `workflow-dsl/v1`;完整格式和最小 Demo 见 [aitable-workflow.md](./aitable/aitable-workflow.md)。删除工作流暂未开放。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -120,6 +149,7 @@
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
@@ -155,22 +185,150 @@
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
### section (文件夹与节点管理)
用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `section list-nodes` | 列出 Base 下全部节点 | `--base-id` | 返回 `{nodeId, nodeType, parentSectionId, name?}`;是 move-node / reorder 的前置定位命令 |
| `section list-empty` | 列出空文件夹 | `--base-id` | 返回 `{sectionId, name, parentSectionId}`,用于清理导航树 |
| `section create` | 创建文件夹 | `--base-id` `--name` | `--parent-section-id` 不传/空串=根目录;`--index` 指定位置 |
| `section rename` | 重命名文件夹 | `--base-id` `--section-id` `--new-name` | — |
| `section delete` | 删除文件夹 | `--base-id` `--section-id` | 不可逆;建议先 list-empty 确认为空 |
| `section reorder` | 调整文件夹顺序 | `--base-id` `--section-id` `--target-index` | 仅当前父级下调序;跨父级用 move-node |
| `section move-node` | 移动节点(跨父级) | `--base-id` `--node-id` `--new-parent-section-id` | `--new-parent-section-id ""`=移到根;`--target-index` 调全局位置 |
#### 创建文件夹
```
Usage:
dws aitable section create [flags]
Example:
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
Flags:
--base-id string Base ID (必填)
--name string 文件夹名称 (必填)
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
```
返回 `data.sectionId` 与 `data.name`。
#### 重命名文件夹
```
Usage:
dws aitable section rename [flags]
Example:
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--new-name string 新的文件夹名称 (必填)
```
#### 删除文件夹
```
Usage:
dws aitable section delete [flags]
Example:
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
```
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
#### 调整文件夹顺序
```
Usage:
dws aitable section reorder [flags]
Example:
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--target-index int 目标位置(0-based)(必填)
```
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
#### 列出空文件夹
```
Usage:
dws aitable section list-empty [flags]
Example:
dws aitable section list-empty --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
#### 列出全部节点
```
Usage:
dws aitable section list-nodes [flags]
Example:
dws aitable section list-nodes --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
#### 移动节点
```
Usage:
dws aitable section move-node [flags]
Example:
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
Flags:
--base-id string Base ID (必填)
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
--target-index int Base 内节点的全局位置(0-based);不传则不调整
```
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
## 复杂操作
### 仪表盘 / 图表(建议顺序)
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
要点:
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
参数约束
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
@@ -184,7 +342,8 @@
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 → `table update`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
@@ -195,12 +354,34 @@
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新 → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"表单/问卷/form/收集信息" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
@@ -210,14 +391,35 @@
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 新建自动化 → 按子文档的最小 Demo 组装完整 DSL,再 `workflow create --dsl @file`
- 修改自动化 → `workflow get` 留底,按最新 DSL 文档生成完整目标 DSL,再 `workflow update --dsl @file`
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- 删除流程:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
@@ -231,8 +433,8 @@ dws aitable base search --query "项目" --format json
# 2. 获取 Base 信息 — 提取 tableId
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取表结构 — 提取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 3. 获取字段目录 — 提取 fieldId
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
@@ -248,8 +450,10 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `tables[].tableId`、视图目录 | 定位数据表和视图;字段需继续调用 `field get` |
| `field get` | `fields[].fieldId` | record 操作的 cells key, field update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
@@ -261,7 +465,7 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
3. 传入 `--base-id` 参数
> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用;
> 如果是用户直接提供的原始 `alidocs` URL,则先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认 `extension=able` 后再继续。
> 如果是用户直接提供的原始 `alidocs` URL,则先按 [链接规范](url-patterns.md#alidocs-url-类型探测流程) probe,确认 `extension=able` 后再继续。
## 注意事项
@@ -274,11 +478,11 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
| 脚本 | 场景 |
|------|------|
| [bulk_add_fields.py](../../scripts/bulk_add_fields.py) | 批量添加字段 |
| [import_records.py](../../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
| [aitable_export_via_task.py](../../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
| [upload_attachment.py](../../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 |
| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
## 相关产品
- [doc](./doc.md) — 富文本文档编辑,不是结构化数据表格
- [doc](../../dingtalk-doc/references/doc.md) — 富文本文档编辑,不是结构化数据表格
@@ -1,6 +1,8 @@
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
>
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
## 准备附件上传
@@ -38,10 +40,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
# → 返回 uploadUrl、fileToken
# 2. PUT 上传(Content-Type 必须与文件类型一致,不能留空)
# 留空或用 curl 默认的 application/x-www-form-urlencoded 都会被 OSS 拒为 403
# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 例:.txt → text/plain,.png → image/png,.xlsx → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
@@ -15,7 +15,7 @@
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
2. **has_more=true 时不能做全局结论** — 数据可能不完整
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
4. **fieldId 必须来自 `field get` 真实返回** — 不要猜测 fieldId
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
@@ -26,7 +26,7 @@
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
| 附件上传 | `attachment upload` + `record update` | 不要在 cells 里伪造附件值 |
| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
@@ -36,7 +36,7 @@
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
| `field create` | `field get --table-id <tableId>` | 新字段是否出现在字段列表中 |
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
## 5. AI 字段注意事项
@@ -7,7 +7,7 @@
## 顶层规则
- cells 的 key **必须是 fieldId**(如 `fldXXX`),不是字段名称
- fieldId 必须从 `table get` 返回中获取
- fieldId 必须从 `field get` 返回中获取
- 不同字段类型的 value 格式不同,混用会报错
- 系统只读字段(creator/lastModifier/createdTime/lastModifiedTime/formula)不可写入
@@ -86,11 +86,16 @@
{"fldDateId": "2026-03-15T09:00+08:00"}
```
**读取**:RFC3339 字符串
**读取**:RFC3339 字符串(带时区)
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
---
### currency(货币)
@@ -236,15 +241,14 @@
### attachment(附件)
**写入**:对象数组,支持 `fileToken` 或 `url` 形式
**写入**:对象数组,**必须使用 `fileToken`**
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
```
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
> URL 形式是 best-effort 异步转存,不保证立即可用。
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
@@ -335,7 +339,7 @@
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
@@ -15,10 +15,8 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
## 要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- `dashboard share get` 可能返回 `404`(`retryable:true`)——从未分享、甚至刚开启分享后立即查都可能 404;`data` 为 `{}`。按可重试错误处理,不要误判为参数拼错。它有时也会返回 `success + data`(如关闭分享之后),行为不稳定,别用它当"是否已分享"的唯一判据。
- `chart share get` 稳定返回 `success + data`(含 `enabled` 等),可用于分享状态判断;从未分享时 `enabled=false`,不会 404。
- ⚠️ **`dashboard share update` 开启 ORG 分享后返回的 `shareType` 是 `"[1]"`(未映射回 `ORG`,服务端已知问题)**;`chart share update --share-type ORG` 则正确返回 `shareType="ORG"`。判断 dashboard 是否 ORG 分享时对 `"[1]"` 做兼容。
- `chart share update` / `dashboard share update` 的 `--enabled` 是字符串 flag:`--enabled false`(空格)和 `--enabled=false` 都能正确关闭分享。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
## dashboard 子命令
@@ -27,25 +25,19 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard arrange` | 自动重排仪表盘图表布局 | `--base-id` `--dashboard-id` | 让服务端重新排布 charts 位置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard share get` | 获取仪表盘分享配置 | `--base-id` `--dashboard-id` | 可能 404,见上方要点 |
| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启,`--share-type` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
| `chart share get` | 获取图表分享配置 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type`,用 `ORG`;部分组织禁用了 `PUBLIC` 公开分享;报 `Illegal argument` 时改用 `ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` |
> `chart create` 的 `--layout` 是**必填**(12 列网格布局,如 `{"x":0,"y":0,"w":6,"h":4}`);不传本地校验直接拒。`chart update` 的 `--config` 也**必填**——即便只想改 layout,也要带完整 config,否则服务端拒绝。
## 配置获取流程
@@ -32,7 +32,7 @@
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `Failed to create field` | config 格式错误或必填项缺失 | 检查 [field-properties](./aitable-field-properties.md) 中该类型的必填 config |
| `field not found` | field-id 不存在 | 用 `table get` 获取最新字段列表 |
| `field not found` | field-id 不存在 | 用 `field get` 获取最新字段列表 |
| formula 创建失败 | 公式语法错误或引用字段名不匹配 | 先 `field get` 确认字段精确名称,再检查公式语法(见 [formula-guide](./aitable-formula-guide.md)) |
| 删除主字段失败 | 主字段(第一列)不可删除 | 改为更新字段名或类型,不能删除 |
@@ -56,7 +56,7 @@
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| filters 无效被忽略 | 根节点不是 and/or,或 operands 格式错误 | 确保 filters 根节点是 `{"operator":"and"/"or", "operands":[...]}` 结构 |
| sort 无效 | fieldId 不存在 | 先 `table get` 确认字段 ID |
| sort 无效 | fieldId 不存在 | 先 `field get` 确认字段 ID |
| 筛选结果为空 | 条件过严或字段值不匹配 | 放宽条件验证;注意 singleSelect 筛选值用 option name 或 id |
### 2.6 导入导出错误
@@ -120,7 +120,7 @@ dws aitable record create \
## 5. 错误预防最佳实践
1. **写记录前先读字段结构** — `field get` 或 `table get` 确认字段类型和 ID
1. **写记录前先读字段结构** — `field get` 确认字段类型和 ID
2. **写字段前先读 field-properties** — 确认 config 的必填项和格式
3. **formula 字段先确认引用字段名** — `[字段名]` 必须精确匹配
4. **options 更新传完整列表** — 更新 singleSelect/multipleSelect 的 options 是全量覆盖
@@ -4,23 +4,21 @@
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
> ⚠️ **两种 format 严格分工**:业务导出格式只用 `--export-format`(excel/attachment 等);全局 `--format` 只控制 CLI 输出格式,推荐保持 `--format json`。旧写法 `--format excel` 仅用于兼容历史脚本,新调用不要使用。
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
```bash
# 第一步:创建任务;--export-format 是业务导出格式,--format json 是结构化输出格式
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000 --format json
# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000 --format json
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
### 参数约束
创建导出任务时统一必传 `--base-id`、`--scope` 和 `--export-format`;下表是不同 scope 的额外必传参数。使用 `--task-id` 轮询时不再传 scope/导出格式。
| scope | 额外必传参数 |
| scope | 必传参数 |
|-------|----------|
| `all` | 无 |
| `all` | 只需 `--base-id` |
| `table` | 必须 `--table-id` |
| `view` | 必须 `--table-id` + `--view-id` |
@@ -113,32 +111,3 @@ dws aitable import data --import-id <importId> --table-id <TABLE_ID> --format js
> **导入数据无法整体撤销**:文件一旦导入成功,数据即写入表中,没有"撤销导入"操作。如需清理导入的测试数据,只能手动通过 `record delete` 逐条或批量删除记录;如果是新建表模式导入的,可以直接 `table delete` 删除整张表。因此:
> - 测试/验证场景建议导入到**独立的测试表或测试 Base**,用完后整体删除
> - 如果用户明确表示不想导入测试数据或要求先预览内容再决定,应先解析文件内容展示给用户确认,而非直接导入
---
## 导入数据:两条链路怎么选
用户给文件让导入 AI 表格时,路径选择决定成败:
> ⚠️ **`import upload` 没有 `--file` flag**(传了会报 unknown flag)。它只申请上传凭证,必填 `--file-name` + `--file-size`,拿到 `uploadUrl` 后要**自己 curl PUT 上传文件**(Content-Type 留空,见上文三步流程),再 `import data`。想省事直接用 `aitable_import_via_task.py` 脚本,它把这三步包好了。
| 用户原话 | 链路 | 命令 / 脚本 | 行为 |
|---------|------|------------|------|
| "把这个 Excel 导入到 AI 表格"(无指定目标表) | **文件导入任务** | `python scripts/aitable_import_via_task.py <baseId> <file>`(推荐)或手动三步 `import upload --file-name x.xlsx --file-size <字节>` → curl PUT → `import data --import-id <ID>` | 服务端解析文件,**新建数据表**,自动识别表头 |
| "把这个 Excel 导入新表 / 自动建表" | 同上 | 同上 | 同上 |
| "把这批记录追加到已有的『成员表』里" | **记录批量写入** | `python scripts/import_records.py <baseId> <tableId> <file>` | 走 `record create`,**写入已有 tableId**,需要字段名匹配 |
| "Excel 列名和表字段对不上但要追加" | 文件导入 + 追加 + 字段映射 | 三步导入后 `import data --import-id <ID> --table-id <TBL> --field-mapping '{"目标":"源"}'` | 服务端按映射追加 |
## 大表 / 长任务超时续等
单次等待窗口很短:`export data` 只有 `--timeout-ms`(默认且**上限 30000 = 30 秒**,没有 `--timeout-sec`,传了会报 unknown flag);`import data` 用 `--timeout`(秒,默认且推荐最大值 30)。窗口内没跑完,命令会返回 `taskId` / `importId`,用同命令带 ID 反复续等即可:
```bash
# 续等导出:拿到 downloadUrl 后再 curl 下载(见下方警告)
dws aitable export data --base-id <B> --task-id <ID> --timeout-ms 30000
# 续等导入
dws aitable import data --import-id <ID>
```
> ⚠️ **`--output` 不会保存导出的 xlsx**:`--output` 是隐藏的全局 flag,作用是把命令的 **JSON 输出**写到文件,实测只生成一个 0 字节文件,不会下载导出内容。正确做法是从 `export data` 返回里取 `downloadUrl`,再 `curl -L "<downloadUrl>" -o out.xlsx` 下载。想省事直接用 `aitable_export_via_task.py` 脚本(它负责轮询 + 下载)。
@@ -14,7 +14,7 @@ Flags:
--table-id string Table ID (必填)
```
返回字段的完整配置(含 options 等)。在 table get 拿到字段目录后,按需展开少量字段的完整配置。
返回字段的完整配置(含 options 等)。不要假设未指定 `--table-ids` 的 `table get` 枚举结果含字段;字段目录和配置以 `field get` 返回为准。
## field create — 创建字段
@@ -31,13 +31,33 @@ Example:
Flags:
--base-id string Base ID (必填)
--name string 要创建的单字段名称(与 --type 配合使用,替代 --fields)
--type string 要创建的单字段类型(参考 table create 字段类型)
--config string 单字段配置,如 options(可选)
--ai-config string 单字段 AI 配置 JSON(可选,用于创建 AI 字段)
--fields string 批量新增字段 JSON 数组,单次最多 15 个 (与 --name/--type 二选一)
--type string 要创建的单字段类型(需要配合 --name,参考 table create 的内置类型)
--config string 单字段配置 JSON(需要配合 --name/--type,结构参考 table create)
--ai-config string 单字段 AI 配置 JSON(需要配合 --name/--type)
--fields string 批量新增字段 JSON 数组,单次最多 15 个;每个字段的配置写在其 config/aiConfig 内
--table-id string Table ID (必填)
```
`field create` 有且只有两种输入模式:
- 单字段模式:必须同时传 `--name` 和 `--type`;`--config`、`--ai-config` 只作为该字段的附加配置。
- 批量模式:只传 `--fields`;字段配置写在数组内各对象的 `config` / `aiConfig` 中。
两种模式严格互斥。`--fields` 不能与 `--name`、`--type`、`--config` 或 `--ai-config` 混用;单独传 `--config` 也会报错,不会被静默忽略。
例如创建单选字段时,单字段模式的 `--config` 是一个配置对象;批量模式则把同一对象放入对应字段元素的 `config`:
```bash
# 单字段模式
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "部门" --type singleSelect \
--config '{"options":[{"name":"技术部"},{"name":"产品部"}]}'
# 批量模式
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--fields '[{"fieldName":"部门","type":"singleSelect","config":{"options":[{"name":"技术部"},{"name":"产品部"}]}}]'
```
允许部分成功,返回结果逐项标明成功/失败状态。
### AI 字段创建示例
@@ -1,5 +1,7 @@
# filters & sort — 筛选排序语法参考
> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
## filters 结构规范
### 强制规则
@@ -7,7 +9,7 @@
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值
4. fieldId 必须通过 `table get` 或 `field get` 获取,不能直接用字段名称
4. fieldId 必须通过 `field get` 获取,不能直接用字段名称
### 精简防呆模板
@@ -49,11 +51,34 @@ CLI 同时兼容两种子条件写法(推荐格式 A):
| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) |
| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` |
| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` |
| `not_before` / `not_after` | 不早于 / 不晚于 | `["fieldId", "dateStr"]` |
| `from_now` | 从现在起 N 天内 | `["fieldId", "天数"]` |
| `date_between` | 日期区间 | `["fieldId", "[startTs, endTs]"]` |
| `not_before` / `not_after` | 不早于(≥) / 不晚于(≤) | `["fieldId", "2026-05-22"]` |
> **操作符拼写必须严格匹配上表**,CLI 会在调用前校验,错误拼写会被拒绝。
>
> **没有 `date_between`(区间)操作符**,也**不支持 `from_now`**——date 字段不支持区间/相对过滤,传了会被 CLI 拒绝。范围查询用 `not_before` + `not_after` 组合,见下方专节。
### 日期字段过滤(date / 创建时间 / 修改时间)
日期类字段的过滤规则与其它字段**不同**,是线上反馈最高频的踩坑点。**经集成测试实测**确认的规则:
1. **只能用日期专用操作符**:`date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`(与前端筛选 UI 的「等于 / 早于 / 晚于 / 早于或等于 / 晚于或等于 / 不为空 / 为空」一一对应)。
2. **比较值用日期字符串**,如 `"2026-05-22"`(也接受 RFC3339 / 毫秒时间戳,内部统一转成毫秒比较)。读取返回的是带时区 RFC3339(如 `"2026-05-22T00:00:00+08:00"`)。
3. **通用操作符 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对 date 字段无效**——无论传 ISO 字符串还是毫秒时间戳,都会**静默返回 0 条**。这是后端 date 字段的比较规则,不是 bug,CLI 也无法在本地拦截(不知道字段类型),务必用对操作符。
4. **没有区间操作符 `date_between`**,也**不支持 `from_now`(相对天数)**——均会静默返回 0 条,CLI 已直接拒绝。范围查询用 `not_before`(≥起点)+ `not_after`(≤终点)两个条件 `and` 组合。
| 需求 | 操作符 | 示例 operands |
|------|--------|--------------|
| 等于某天 | `date_eq` | `["fldDate", "2026-05-22"]` |
| 早于 / 晚于(不含当天) | `before` / `after` | `["fldDate", "2026-05-22"]` |
| 不早于(≥) / 不晚于(≤) | `not_before` / `not_after` | `["fldDate", "2026-05-22"]` |
| 有值 / 为空 | `exist` / `un_exist` | `["fldDate"]` |
**日期区间查询(替代 between)**——查 `2026-05-01 ~ 2026-05-31`(含端点):
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"not_before","operands":["fldDate","2026-05-01"]},{"operator":"not_after","operands":["fldDate","2026-05-31"]}]}'
```
### 常见错误拼写(CLI 会自动提示纠正)
@@ -104,3 +129,41 @@ dws aitable record query --base-id X --table-id Y \
```bash
--sort '[{"fieldId":"fldPriority","direction":"desc"},{"fieldId":"fldCreatedAt","direction":"asc"}]'
```
---
## view update --config 中的 filter / sort 格式
> **重要区分**:`record query --filters` 和 `view update --config` 中的 filter **格式不同**!
| 场景 | filter 格式 | 说明 |
|------|-------------|------|
| `record query --filters` | **对象**:`{"operator":"and","operands":[...]}` | 直接传最外层逻辑对象 |
| `view update --config` 的 filter | **数组**:`[{"operator":"and","operands":[...]}]` | 外面多一层数组包裹 |
| `view update --config` 的 sort | **数组**:`[{"fieldId":"X","direction":"asc"}]` | 与 record query --sort 一致 |
### 正确示例
```bash
# view update 设置筛选(filter 是数组)
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"filter":[{"operator":"and","operands":[{"operator":"eq","operands":["fldStatus","待处理"]}]}]}'
# view update 设置排序(sort 是数组)
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"sort":[{"fieldId":"fldPriority","direction":"desc"}]}'
# 同时设置 filter + sort + visibleFieldIds
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"filter":[{"operator":"and","operands":[{"operator":"eq","operands":["fldStatus","进行中"]}]}],"sort":[{"fieldId":"fldDate","direction":"asc"}],"visibleFieldIds":["fld1","fld2","fld3"]}'
```
### CLI 自动容错
CLI 会自动修正以下常见错误格式(不会报错,但建议直接使用正确格式):
| 错误写法 | CLI 自动修正为 |
|----------|---------------|
| `"filter":{"operator":"and",...}` (对象) | `"filter":[{"operator":"and",...}]` (数组) |
| `"sort":{"fieldId":"X","direction":"asc"}` (对象) | `"sort":[{"fieldId":"X","direction":"asc"}]` (数组) |
| 子条件用 MCP 简写 `{"fieldId":"X","operator":"eq","value":"Y"}` | 自动转为 `{"operator":"eq","operands":["X","Y"]}` |
@@ -50,8 +50,8 @@ dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 每条返回 viewId/name;新建表单**无 title 且 createdAt=0**,改过(form update)后才出现 title 和真实 createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 客户端按 viewId 过滤后 `data` **即该表单对象**(不是 formViews 数组);viewId 不存在返回 `form view ... not found` 错误 |
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
@@ -115,6 +115,6 @@ dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VI
## 返回结构补充
- `form list` 返回 `data.formViews[]`,**每条含** `viewId/name`(+ `createdAt`);**新建表单没有 `title` 字段且 `createdAt=0`**,只有在 `form update` 碰过之后才会出现 `title` 和真实 `createdAt`。`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 的 `data` **就是命中的那一条表单对象**(如 `{viewId, name, createdAt, title?, shareFormUuid?}`),不是 `formViews` 数组。Agent 直接读 `data.viewId` / `data.name` 即可,**不要**再取 `data.formViews[0]`。服务端的 viewIds 过滤参数当前不生效,CLI 在客户端按 viewId 精确筛出单条;传了不存在的 viewId 会返回 `form view <id> not found in table` 错误。
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
@@ -17,9 +17,7 @@ dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --recor
- `--table-id`(必填):Table ID
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。
> **该记录尚未创建主键文档时**:不会返回 `nodeId: null`,而是 `status=error`、`data={}`,`error={code:"-1", message:"no record", type:"SYSTEM_ERROR", retryable:true}`。要判断"有没有主键文档",看是否命中这个 `no record` 错误,而不是判断 `nodeId` 是否为 null。需要文档时改用 `primary-doc-create`(幂等,已存在则直接返回)。
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
### 创建主键文档
@@ -30,7 +28,7 @@ dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --fi
**参数:**
- `--base-id`(必填):Base ID
- `--table-id`(必填):Table ID
- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable table get` 查看字段类型)
- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable field get` 查看字段类型)
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 创建或已存在的主键文档 nodeId。
@@ -46,8 +44,8 @@ dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --fi
## 典型工作流
```bash
# 1. 查询表结构,拿到 primaryDoc 字段的 fieldId
dws aitable table get --base-id BASE_ID --table-ids TABLE_ID
# 1. 查询字段目录,拿到 primaryDoc 字段的 fieldId
dws aitable field get --base-id BASE_ID --table-id TABLE_ID
# 2. 为某条记录创建主键文档
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
@@ -25,21 +25,28 @@ Flags:
|------|------|
| 参数名用 `--data` | ❌ 参数名是 `--records`,不是 `--data` |
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
| 不先获取 fieldId | ❌ 必须先 `field get` 获取 fieldId,再写入记录 |
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
| 附件/图片字段直传 URL | ❌ 严禁 `{"url":"https://..."}` — 会触发 TIMEOUT_ERROR。必须先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。详见 [aitable-attachment.md](./aitable-attachment.md) |
## 正确流程
```bash
# 先获取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 从返回中提取 fieldId(如 fldABC123)
# 再用 fieldId 写入记录
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldABC123":"Python入门"}}]' --format json
# 从创建响应的 data.newRecordIds[] 提取新 ID,并回读确认真实写入值
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
--record-ids <NEW_RECORD_ID> --format json
```
创建成功以 `data.newRecordIds[]` 为 ID 来源;不要把整个 `data` 当作单个 recordId,也不要只以退出码作为写入成功证据。
## cells 写入格式
各字段类型的写入格式见 [aitable-cell-value.md](./aitable-cell-value.md)。
@@ -25,11 +25,12 @@ dws aitable record history-list \
"data": {
"histories": [
{
"type": "row", // 变更类型,实测均为 "row"(行级变更)
"action": "updateRecords", // 操作动作:appendRow(新增行) / updateRecords(更新记录)
"newValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改后值\"}}", // 变更后的值(JSON 字符串,按 fieldId 组织)
"oldValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改前值\"}}", // 变更前的值(appendRow 新增行时无此字段)
"type": "field_change", // 变更类型
"action": "update", // 操作动作: create / update / delete
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
"version": 7 // 版本号(单调递增)
}
]
@@ -37,14 +38,14 @@ dws aitable record history-list \
}
```
`newValue` / `oldValue` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值;解析后是 `{fieldId: {dataType, value}}` 结构。`appendRow`(新增行)事件没有 `oldValue`。实测返回里**没有** `typeChangedFields` 字段。
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
## 字段含义速查
| 字段 | 用途 |
|------|------|
| `type` | 实测均为 `row`(行级变更),服务端未按 `record_create` / `field_change` 细分。 |
| `action` | 底层操作名:`appendRow`(新增行)/ `updateRecords`(更新记录)。按"动作"统计时以这两个值为准。 |
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
@@ -73,22 +74,20 @@ dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
| jq '[.data.histories[] | select(.action == "updateRecords")][0].oldValue'
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
```
### 4. 只看更新事件(排除新增行)
### 4. 找出删除事件(如果存在 delete history)
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | select(.action == "updateRecords") | {version, operateTime}'
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
```
> 记录被 `record delete` 删除后,其历史不再返回(`histories` 为空数组),无法通过本命令回溯删除事件。
## 注意事项
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
- 仅返回**新增行**(appendRow)与**字段值变更**(updateRecords);记录被删除后其历史不再可查(返回空)。视图、字段定义、表结构变更不在此 history 里。
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
- 历史保留时长由 server 决定,过老的记录可能不再返回。
## 与其他 record 命令的关系
@@ -72,3 +72,49 @@ dws aitable record query --base-id X --table-id Y --all --cursor "上次返回
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
## record query-empty — 找空行
`record query-empty` 是与 `record query` 平行的独立子命令,专门按表内顺序扫描出"完全没填用户字段"的空行。
```bash
dws aitable record query-empty --base-id BASE_ID --table-id TABLE_ID
```
| flag | 说明 |
|------|------|
| `--base-id` / `--base` | 必填 |
| `--table-id` | 必填 |
| `--limit` | 单次**扫描预算**(不是返回数);范围 [1, 100],默认 100 |
| `--cursor` | 分页游标。响应中 `nextCursor` 非空 → 用它翻页继续扫;nextCursor 为空(或不存在)→ 已扫完整表 |
返回结构:
```jsonc
{ "data": { "records": [...], "nextCursor": "..." } }
```
### 关键语义
1. **`--limit` 是扫描预算不是返回数**:可能扫了 100 条但全部非空,本页 `records: []`。
2. **本页空 records ≠ 全表无空行**:必须看 `nextCursor`,nextCursor 还在就要继续翻。
3. **空行定义**:除系统字段(recordId / 创建人 / 创建时间 / 修改人 / 修改时间)外,所有 cell 都是 null、空字符串、空集合或空 Map。一般是用户在 UI 上"插入空行"产生的。
### 典型用法
```bash
# 扫一页,看本页有没有空行
dws aitable record query-empty --base-id BASE --table-id TBL
# 翻页
dws aitable record query-empty --base-id BASE --table-id TBL --cursor <上次的nextCursor>
# 把整表扫完(手动循环 cursor)
NC=""
while : ; do
R=$(dws aitable record query-empty --base-id BASE --table-id TBL ${NC:+--cursor "$NC"} --format json)
echo "$R" | jq '.data.records[] | .recordId'
NC=$(echo "$R" | jq -r '.data.nextCursor // empty')
[ -z "$NC" ] && break
done
```
@@ -10,21 +10,39 @@ Example:
--records '[{"recordId":"recXXX","cells":{"fldStatusId":"已完成"}}]'
Flags:
--base-id string Base ID (必填)
--records string 待更新记录 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一)
--records string 待更新记录 JSON 数组,单次最多 100 条;cells key 支持 fieldId 或当前表内唯一字段名,推荐 fieldId (必填,与 --records-file 二选一)
--records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境)
--table-id string Table ID (必填)
```
只需传入需修改的字段,未传入的保持原值。每条记录必须含 recordId 和 cells。
## 高频错误 flag(LLM 极易踩坑,必读)
## cells key:优先使用 fieldId,也支持唯一字段名
CLI **没有** `--record-id` 和 `--cells` 两个独立 flag,**只接受 `--records` 一个参数**,格式为 JSON 数组。
即使只改一条记录,也必须包在数组里。
`cells` 的 key 有两种写法:
| 错误(LLM 直觉) | 正确 |
- fieldId(推荐):不受字段重命名或重名影响,通过 `field get` 获取。
- 当前表内唯一的字段名:按名称精确匹配;如果存在同名字段,必须改用 fieldId。
同一字段同时通过 fieldId 和字段名传入时,fieldId 对应的值优先。
```bash
# 推荐:fieldId
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"fldStatusId":"已完成"}}]' --format json
# 便捷写法:当前表内唯一字段名
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"状态":"已完成"}}]' --format json
```
## 推荐参数形式
公开、稳定的批量入口是 `--records`(或 `--records-file`),格式为 JSON 数组;即使只改一条记录,推荐也包在数组里。CLI 仍保留隐藏的 `--record-id` + `--cells` 兼容入口,但它不会出现在常规帮助中,自动化脚本应优先使用 `--records`。
| 不推荐或无效写法 | 推荐写法 |
|---|---|
| `--record-id recXXX --cells '{"fldX":"值"}'` | `--records '[{"recordId":"recXXX","cells":{"fldX":"值"}}]'` |
| `--record-id recXXX --cells '{"fldX":"值"}'`(隐藏兼容入口) | `--records '[{"recordId":"recXXX","cells":{"fldX":"值"}}]'` |
| `--id recXXX --data '{"fldX":"值"}'` | 同上 |
| `--record-id recXXX --field fldX --value "新值"` | 同上 |
@@ -33,8 +51,14 @@ CLI **没有** `--record-id` 和 `--cells` 两个独立 flag,**只接受 `--re
```bash
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"<RECORD_ID>","cells":{"<FIELD_ID>":"新值"}}]' --format json
# 从更新响应的 data.recordIds[] 提取成功记录 ID,并回读确认真实值
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
--record-ids <RECORD_ID> --format json
```
更新响应不返回“受影响字段”;以 `data.recordIds[]` 确定成功记录,再用查询回读验证。
## 引号转义提示
- Linux/macOS:外层用单引号 `'[...]'`,内部 JSON 用双引号即可
@@ -16,7 +16,29 @@
> card 在 Kanban 走 `kanbanCard`,在 Gallery 走 `galleryCard`,CLI 自动按 viewType dispatch(preflight 1 次 `get_views`)。timebar 仅 Gantt 支持;Calendar 服务端未暴露任何 timebar 配置。
> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`create_view` 的 `--config` 中传入 `ganttTimebar` 会被服务端忽略。
> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`view create --config` 不接受 `ganttTimebar`,请在创建后使用专属子命令。
## 创建:view create
`--view-type` 支持 `Grid`、`Kanban`、`Gantt`、`Calendar`、`Gallery`、`FormDesigner`。创建时通过 `--config` JSON 设置可见字段:
```bash
# --config JSON:可同时配置可见字段、筛选、排序和分组;主字段必须排第一
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
--view-type Grid --name "任务视图" \
--config '{"visibleFieldIds":["fldPrimary","fldStatus","fldOwner"],"sort":[{"fieldId":"fldStatus","direction":"asc"}]}'
```
创建阶段的 `--config` 是 JSON 对象,并且只接受以下 4 个 key:
| key | 类型 | 说明 |
|---|---|---|
| `visibleFieldIds` | `string[]` | fieldId 数组,不接受字段名;至少一个,主字段必须排第一 |
| `filter` | `object[]` | 筛选规则数组;兼容单个 object,CLI 会自动包装为数组 |
| `sort` | `object[]` | 排序规则数组;兼容单个 object,CLI 会自动包装为数组 |
| `group` | `object[]` | 分组规则数组;兼容单个 object,CLI 会自动包装为数组 |
描述使用独立的 `--desc '{"content":[]}'`;`description`、`fieldWidths`、`aggregate`、`kanbanCard`、`ganttTimebar`、`galleryCard` 等其他 key 会在调用服务端前被拒绝,并提示对应的 `view update` 子命令。
## 读取:view get <attr>
@@ -79,21 +101,21 @@ dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
### view update aggregate(仅 Grid)
值是 `map[fieldId]→AggregateAction string`。**设置**聚合可用;**清除**聚合当前无效(见下方警告)。
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
| flag | 类型 | 说明 |
|------|------|------|
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,本意是清除其聚合,但**当前服务端不支持清除,静默无效**(见下方警告) |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
| `--json` | JSON | 完整 aggregate map |
```bash
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
```
> ⚠️ **当前无法清除已设置的聚合(服务端限制)**:`--clear-field-id fldA,fldB` 与 `--json '{"fldX":null}'` 两种清除写法都返回 `success`,但用 `view get aggregate` 复核会发现聚合**原样不动**——是静默无效,不是真的清掉。这是服务端没有清除语义所致,直至服务端修复前不要依赖它。改聚合方式可行(重新 `--action` 覆盖成别的),只是无法回到"无聚合"。
### view update field-widths(仅 Grid)
| flag | 类型 |
@@ -108,11 +130,9 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
### view update visible-fields(通用)
整组替换可见字段列表与顺序,同时兼作**隐藏/显示**入口。首列字段(primaryDoc)必须保留在数组第一位。
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
> **传入的列表既定顺序又定可见性**:传一个比当前 columns 短的列表,缺失的字段会被真正隐藏(该字段在 `view list` 的 `custom.hiddenFields` 里变 `true`);再传回全量列表即可解除隐藏(`hiddenFields` 变 `false`)。
>
> ⚠️ **查隐藏状态别看这里**:`view get visible-fields` 返回的数组**包含已隐藏字段**(列的完整顺序),看不出谁被隐藏。要确认隐藏状态,读 `view get`(view list)里该视图的 `custom.hiddenFields`(`{fieldId: true|false}`)。
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
| flag | 类型 |
|------|------|
@@ -120,9 +140,7 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
```bash
# 只保留首列和 fldA,其余字段被隐藏
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA
# 传回全量列表解除隐藏
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
```
@@ -54,7 +54,7 @@ dws aitable view update frozen-cols --view-id VIEW_ID --count 0
# 查询当前冻结列数
dws aitable view get frozen-cols --view-id VIEW_ID --format json
# → {"data": {..., "count": 1}} 未显式设置时整个 count 键缺失(不是返回 null)
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
```
`--count` 必须 ≥ 0;负数会被拒绝。
@@ -69,7 +69,7 @@ dws aitable view update row-height --view-id VIEW_ID --cell-height 56
# 查询当前行高
dws aitable view get row-height --view-id VIEW_ID --format json
# → {"data": {..., "cellHeight": 56}} 未显式设置时整个 cellHeight 键缺失(不是返回 null;前端按 32 渲染)
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
```
## 数据高亮规则(条件填色,仅 Grid)

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