Compare commits

...
Author SHA1 Message Date
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
Dennis ecbd2e3009 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 09:57:46 +08:00
Dennis 33df6ee794 test(chat): close IM shortcut coverage gaps 2026-07-29 09:46:00 +08:00
github-actions[bot] 18e1c7870e Merge pull request #676 from typefield/feat/command-surface-naming
feat(helpers): declarative LeafSpec command framework + devapp migration
2026-07-29 09:43:34 +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
玉澜 1ff4941082 Merge remote-tracking branch 'upstream/main' into feat/command-surface-naming 2026-07-29 00:53:26 +08:00
玉澜 f5d57c2e07 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync 2026-07-29 00:32:22 +08:00
Dennis f3231ed2a8 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/shortcut/chat/compatibility_coverage_test.go
#	internal/shortcut/smart/compatibility_coverage_test.go
2026-07-29 00:29:53 +08:00
玉澜 7762abc1ae refactor(helpers): drop unused LeafInt64 kind (CR C1)
No production LeafSpec uses LeafInt64; devapp only needs LeafInt
(non-zero-only putInt semantics). The default MCP dispatch and Server
routing stay — they are the framework's documented main path for
future MCP-direct products.
2026-07-29 00:29:21 +08:00
Dennis 8d1b76caa7 feat(chat): align and harden IM shortcuts 2026-07-29 00:21:26 +08:00
玉澜 9d0995a61d test(helpers): cover parse-error path in required validation 2026-07-28 23:02:06 +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
玉澜 571eb20457 refactor: align required/args semantics, trim-aware fallback, helper dedupe
Post-review cleanup round:
- leaf.go: required validation now matches leafArgs inclusion rules
  (LeafInt explicit 0 / LeafInt64 <= 0 count as missing) via
  leafHasEffectiveValue; fallback-chain candidates are judged after
  TrimSpace when Trim is set so pure-whitespace values fall through.
- command_meta.go: drop catalogStringVal/catalogStringSliceVal in favor
  of existing schemaString/schemaStringSlice.
- fetch_mcp_metadata: cross-owned canonicals skip name-coincidence
  direct merges; the reviewed cross-server identity is the sole source.
2026-07-28 22:52:33 +08:00
github-actions[bot] 7937d09eed Merge pull request #757 from DingTalk-Real-AI/fix/shortcut-audit-batch
fix(shortcut): 修复按姓名解析漏掉外部联系人 + resource-url 补 --msg-id 别名
2026-07-28 22:32:59 +08:00
玉澜 bc39d24559 fix(fetch-mcp-metadata): refresh cross-server tools via reviewed interface_refs
Live matching only recognized srv.ID+"."+name == registry canonical, so
the 101 canonicals whose reviewed interface_ref routes to a differently
named server/tool were silently skipped and stayed frozen at the
previous snapshot (or degraded to stubs). Build a reverse index from the
previous snapshot's reviewed interface_refs (live key → canonicals) and
fan the live descriptor out to every owning canonical, preserving the
reviewed ref through the existing merge semantics.
2026-07-28 22:04:32 +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
玉澜 3717053d24 chore(helpers): drop dead devapp flag-registration helpers
addDevAppVersionLocatorFlags and registerDevAppMemberMutationFlags lost
their last callers when the dev app command surface was reworked; the
uncovered dead code regressed overall coverage below the merge base.
2026-07-28 21:33:26 +08:00
玉澜 2a3df50d0f refactor(cli): deterministic alias collision resolution and helper cleanup
alias-vs-alias collisions in the command meta lookup now resolve to the
owner with the lexicographically smallest primary path instead of map
iteration order. Move catalogStringVal next to its sibling helpers in
command_meta.go and drop the redundant captureBaseHelpFunc alias in the
calendar help wrapper. Unify the Safety help annotation to English
"(requires --yes)".
2026-07-28 21:13:49 +08:00
玉澜 03b3cf68e4 feat(coverage-gate): log files exempted for having no executable statements
Silently dropping non-executable changed files made the exemption
invisible in CI logs; each exempted path is now reported to stderr in
sorted order.
2026-07-28 21:13:40 +08:00
玉澜 bbdf843ef3 fix(fetch-mcp-metadata): count registry stubs as unmatched in coverage
matched_tools claimed every surface tool matched even when entries were
registry stubs with no live MCP metadata, and unmatched_tools was
hardcoded to 0. Coverage now excludes stubs from matched_tools, reports
them as unmatched, and a registry JSON parse failure warns instead of
silently producing a stub-only snapshot. The schema catalog policy
invariant is relaxed to match the honest accounting.
2026-07-28 21:13:40 +08:00
玉澜 eb2658ca68 fix(helpers): honor alias/env/default fallback for integer leaf flags
The leaf fallback chain read only string flags, so LeafInt/LeafInt64
flags could never satisfy Required via alias or env, alias values for
integer flags were silently dropped, and a registered Default shadowed
alias/env values. Resolution order is now explicit flag > alias > env >
Default > ArgDefault, aliases register with the primary flag's Kind, and
unparsable integer env values fail loudly.
2026-07-28 21:13:29 +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
Dennis a5ac09218b fix(chat): close IM shortcut validation gaps 2026-07-28 20:55:25 +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
DennisandClaude Opus 4.8 d41214988a fix(shortcut): keep external contacts in name resolution; alias resource-url msg-id
Two independent shortcut correctness fixes surfaced by the audit:

- Name→ID resolution (chat +dm / +broadcast / … via the shared resolver) dropped
  every search_contact_by_key_word row with an empty userId. External /
  cross-org contacts arrive with only an openDingTalkId, so they were silently
  discarded — making resolution report a real person as missing, or collapse to
  the wrong single match when an in-org namesake existed. Keep any row with at
  least one usable identity (userId or openDingTalkId) and fall the display name
  back through nick/showName/flowerName/staffName/userName.

- chat +messages-resource-url required --message-id with no alias, so an agent
  copying the message list's openMessageId/msgId output field hit "unknown
  flag". Accept --msg-id / --open-message-id as aliases (declared via an
  at-least-one constraint since a shortcut's Required check only sees the
  primary flag name), mirroring the earlier chat message download-media fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:11:26 +08:00
Dennis bfb812bfa0 feat(chat): publish and harden all IM shortcuts 2026-07-28 18:59:04 +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
玉澜 4b0f71eedc test: close changed-code coverage gaps to satisfy the coverage gate
- fetch_mcp_metadata: extract run()/resolveToken()/writeMetadata with
  injectable deps (keychain, servers, lister, registry, exit); full-path
  tests reach 100% file coverage.
- internal/cli: drop dead initSafetyByCLIPath (superseded by ResolveMeta),
  split buildMetaByCLIPath / assembleSchemaCatalogSnapshot /
  assembleCommandRegistryFrom / mergedCommandRegistryJSON so shard and
  malformed-snapshot failure modes are testable; cover catalog structure
  violation formatting (sort/truncate) and RenderSafetyAnnotation.
- generators: cover registry shard merge and catalog shard write failure
  modes.
- helpers/cmdutil: cover LeafSpec default/server dispatch, transform error
  propagation, default env hint, devapp member remove validate chain, and
  the required-flags error helpers.

Local gate: overall 90.17% vs merge-base 89.96%, changed-code 100%
(861 statements); make policy and go test ./... green.
2026-07-28 18:05:20 +08:00
玉澜 5c30d01522 fix(coverage-gate): exempt files without executable statements
A changed production Go file with no function bodies (pragma carriers such
as internal/cli/gen.go, doc-only files) can never appear in a coverage
profile, so the missing-profile check failed every PR touching one. Parse
changed files and exempt those without executable statements; unreadable
or unparsable files stay conservative.
2026-07-28 18:05:19 +08:00
玉澜 c94190f90e fix: honor alias/env fallback for plain required LeafSpec flags
Plain Required now validates the effective value (primary flag -> aliases
-> env) instead of only the primary flag, matching the declared fallback
semantics; whitespace-only values under Trim count as missing. Extracted
cmdutil.MissingRequiredFlagsError to keep the unified error format.
2026-07-28 16:48:42 +08:00
玉澜 6763ddd154 fix: resolve command metadata via compat aliases
ResolveMeta copies Catalog aliases into CommandIdentity and registers each
alias path against the same metadata (primary cli_path wins on collision),
so compat paths like 'report list' resolve instead of returning ok=false.
2026-07-28 16:48:42 +08:00
玉澜 235cad4cc7 fix: report honest MCP snapshot coverage
snapshot_services now counts only services whose tools/list succeeded and
missing_services names the failures, so a partially failed refresh can no
longer write a snapshot that claims full coverage.
2026-07-28 16:48:42 +08:00
玉澜 96d0d430e6 Merge upstream main into feat/command-surface-naming 2026-07-28 16:12:38 +08:00
github-actions[bot] 5783c4e82a chore: update beta formula for v1.0.55-beta.5 [skip ci] 2026-07-28 07:10:13 +00:00
chichuanandchichuan baafd6fe7d docs(CHANGELOG): 补充 v1.0.55-beta.5 精确发布说明(风险等级:文档级) (#812)
Co-authored-by: chichuan <haofeng.hf@alibaba-inc.com>
2026-07-28 15:02:24 +08:00
github-actions[bot] 23c3b74979 Merge pull request #803 from DingTalk-Real-AI/codex/fix-contract-defects
fix: harden dws contract edge cases
2026-07-28 14:46:06 +08:00
Dennis 258e7ed872 fix: align doc rename schema contract 2026-07-28 14:30:29 +08:00
Dennis 69d813afef fix: address contract review feedback 2026-07-28 12:09:57 +08:00
Dennis 00305d941d fix: preserve document info schema compatibility 2026-07-28 11:37:32 +08:00
Dennis ec7fdb0f0d fix: harden dws contract edge cases 2026-07-28 11:36:44 +08:00
github-actions[bot] a8e83e5e7e Merge pull request #804 from wxianfeng/feature/aone84760010-qwenwork-agent-host
feat: add agent host observation metadata
2026-07-28 03:02:43 +00:00
修雨 488d90b73c Merge branch 'main' into feature/aone84760010-qwenwork-agent-host 2026-07-28 10:51:27 +08:00
修雨 2c10be2a1a feat(schema): publish 210 built-in shortcuts (#802)
Publishes all 210 public built-in shortcuts as reviewed Agent-visible
leaf tools across 16 product groups, with stable canonical identities,
executable +shortcut CLI paths, parameter and cross-parameter
constraints, selection guidance, interface metadata, and runtime-aligned
safety/confirmation semantics. Catalog grows from 603 to 813 tools.
2026-07-28 00:11:29 +08:00
wxianfeng 3985c4c98f feat: add agent host observation metadata (Aone 84760010) 2026-07-27 22:09:58 +08:00
wxianfeng 196bf929c1 Merge remote-tracking branch 'upstream/main' 2026-07-27 20:50:49 +08:00
github-actions[bot] 2dfc39f0d3 Merge pull request #790 from wxianfeng/feature/dws-event-im-phase3
feat(event): add multi-event and group lifecycle subscriptions
2026-07-27 10:00:25 +00:00
wxianfeng 97a16c43b4 fix(event): harden targeted consumer stop 2026-07-27 17:50:31 +08:00
wxianfeng afe7d860ff Merge remote-tracking branch 'upstream/main' 2026-07-27 15:57:39 +08:00
wxianfeng 63b3e72fad test(event): close coverage gate gaps 2026-07-27 15:35:49 +08:00
wxianfeng 984529b4cb test(event): cover multi-event edge paths 2026-07-27 14:59:00 +08:00
wxianfeng 298ea5b341 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	CHANGELOG.md
2026-07-27 13:47:23 +08:00
修雨 5c01ae845f fix: move event changelog entry to [Unreleased] + update npm propagation test
- Add missing ## [Unreleased] heading required by Policy CI check
- Update npm test assertion (12 → 60) to match extended propagation wait
2026-07-25 09:44:39 +08:00
修雨 36b58d08b0 Merge branch 'main' into feature/dws-event-im-phase3 2026-07-25 09:33:53 +08:00
wxianfeng 3749c6b793 docs: update changelog for personal events 2026-07-24 17:59:19 +08:00
wxianfeng e2390c3385 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	skills/mono/SKILL.md
2026-07-24 16:31:29 +08:00
wxianfeng 125bc88d52 fix(event): switch personal defaults to production 2026-07-24 15:40:37 +08:00
炳昱 6b9fcf9289 fix(event): preserve nested message context when flattened 2026-07-24 14:26:34 +08:00
wxianfeng 5a3617c21d feat(event): flatten group member events 2026-07-23 15:30:49 +08:00
玉澜 0d866911e0 fix: refresh existing MCP metadata 2026-07-23 14:20:23 +08:00
玉澜 2bf5401f55 fix: load split registry for MCP metadata refresh 2026-07-23 14:16:27 +08:00
玉澜 c76c30a0b7 Merge upstream main into feat/command-surface-naming 2026-07-23 14:12:18 +08:00
wxianfeng 1c9b09b23f Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/app/event_command.go
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/event.json
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/selection/event.json
#	internal/event/personal/registry_test.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-22 11:54:32 +08:00
wxianfeng 4cded1ef6c Merge remote-tracking branch 'upstream/main' 2026-07-21 19:24:43 +08:00
炳昱 0182060757 fix(skill): advertise group member event triggers 2026-07-21 17:05:43 +08:00
wxianfeng c9cf1cc9c1 feat(event): support multi-event consume 2026-07-21 16:59:36 +08:00
炳昱 b234015e7f feat(event): add group member lifecycle events 2026-07-20 16:10:47 +08:00
wxianfeng ca6e520610 chore(event): default personal events to pre-release 2026-07-20 11:47:55 +08:00
wxianfeng b731050dad feat(event): add all-message and group lifecycle events 2026-07-20 11:32:46 +08:00
wxianfeng bb2dd2ba76 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3 2026-07-20 10:08:19 +08:00
玉澜 51dc237d8a feat: declarative LeafSpec command framework + schema generation/consumption separation
== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.

== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.

== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
  flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.

== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.

== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.

== AGENTS.md ==
Documents the generation/consumption split.

Verified: make policy exit 0, drift zero, all tests pass.
2026-07-19 09:38:43 +08:00
wxianfeng 049af9fc30 Merge remote-tracking branch 'upstream/main' 2026-07-17 17:38:04 +08:00
wxianfeng d19a15a6ed Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli
# Conflicts:
#	.github/badges/coverage.svg
2026-07-17 17:36:26 +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
github-actions[bot] e85d9bc314 chore: update coverage badge [skip ci] 2026-07-13 02:36:36 +00:00
537 changed files with 730447 additions and 478443 deletions
+41 -5
View File
@@ -7,7 +7,8 @@ unrelated work, and use `gofmt` for every modified Go file.
- Build: `go build ./cmd`
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
- Generate Schema assets: `go generate ./internal/cli`
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
@@ -56,8 +57,16 @@ The Schema data flow is one way:
6. One-way publication
SchemaRegistry
└─ internal/cli/schema_catalog.json
└─ internal/cli/schema_catalog/
(catalog.json + tools/<product>.json; split per product so
concurrent feature PRs only rewrite their own shard)
└─ dws schema list/product/group/leaf/--all
7. Runtime consumption (unified API)
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
└─ internal/cli/command_meta.go
└─ all consumers (help, schema, agent, skill-gen) call this one function
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
```
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
@@ -73,11 +82,37 @@ Build-time gates and the snapshot serializer consume that source-resolved typed
registry/index. Runtime projections and delivery gates consume the typed
registry/index returned by the production snapshot loader. Neither path may
reopen annotations, merge source records, or use a previous Catalog or other
generated JSON as a source. `schema_catalog.json` is output-only in the
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
tools/<product>.json shards) is output-only in the
generation graph. The production loader decoding the embedded published
snapshot is a delivery boundary, not source resolution; it must never create or
repair a Cobra command, flag, registry entry, or later Catalog generation.
### Generation vs consumption separation
The Schema system has two physically separated processes:
**Generation** (build-time, slow, reviewed, one-way):
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
not in business code).
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
(standalone Go mains).
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
endpoints, merges with previous data for cross-server interface_ref).
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
`check-command-surface.sh` (catalog structure).
**Consumption** (runtime, fast, read-only, unified API):
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
in `internal/cli/command_meta.go`.
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
`dws schema`, agent selection, future skill generation.
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
This split is architecturally isomorphic to Lark's typed metadata registry,
navigation catalog, and schema renderer. DWS intentionally preserves its
existing flat JSON wire contract for compatibility; do not treat architectural
@@ -117,7 +152,8 @@ When adding or changing an Agent-visible command, review all relevant inputs:
must never materialize, infer, or override registry identity.
- Flag-to-interface property mappings and required/default semantics.
- Generated files under `internal/cli/schema_agent_metadata/` and
`internal/cli/schema_catalog.json` after running generation.
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
running generation.
Run the reverse-completeness tests whenever the Cobra tree changes. A command
that works through `dws <path>` but cannot be found through the matching
@@ -177,7 +213,7 @@ For every curated tool:
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
`review_reason`, `source_refs`).
3. Run `make generate-schema`. Do not hand-edit generated
`schema_agent_metadata/` or `schema_catalog.json`.
`schema_agent_metadata/` or `schema_catalog/`.
### Pull live MCP descriptions (personal token)
+64
View File
@@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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
This beta validates expanded personal event consumption, complete Agent-visible
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
observability, and hardened document, Drive, approval, and Todo command
contracts on top of the `v1.0.55-beta.4` baseline.
### Added
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
### Fixed
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
## [1.0.55-beta.4] - 2026-07-27
This beta validates the shortcut projection fixes for group bots, bot search,
+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.4"
version "1.0.55-beta.5"
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.4/dws-darwin-arm64.tar.gz"
sha256 "05b269fe44a125ee8b368d6228c5229950b8216872fb569741d1e30a83ce952a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-amd64.tar.gz"
sha256 "b0d7604299336c83b7805d3b1a47a90668f2e2f3fc54702b0e846bb2907b6170"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
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.4/dws-linux-arm64.tar.gz"
sha256 "a9c1dd5c6171091a84fc18e5081c9f75d037826cd3966726545d715ce832ae31"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-amd64.tar.gz"
sha256 "5e97ba398f5a3e15b9d235bc53f596d31a4d6b7af7bb2185b7b48beeda6a2ebb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-skills.zip"
sha256 "4ebc0294b65d90adb5c5d639a548b528af240e4117ccca3d388e2efb6030170a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
end
def install
+11 -7
View File
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -129,16 +129,16 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp); \
registry_guard=$$(mktemp -d); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
@@ -153,14 +153,18 @@ generate-schema:
generate-schema-agent-metadata:
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
-root . \
-registry internal/cli/schema_command_registry.json \
-registry internal/cli/schema_command_registry \
-output-dir internal/cli/schema_agent_metadata \
-audit-output internal/cli/schema_agent_metadata_audit.json
generate-schema-catalog:
$(GO) run -a ./internal/generator/cmd_schema_catalog \
-root . \
-output internal/cli/schema_catalog.json
-output internal/cli/schema_catalog
fetch-mcp-metadata:
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
@./scripts/dev/fetch_mcp_metadata.sh
package:
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
+21 -1
View File
@@ -474,7 +474,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -503,6 +503,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# Inspect local consumers and cancel a subscription
dws event status
dws event stop <subscribe_id>
@@ -514,6 +533,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
| Shared connection | Consumers for the same user share one local bus and cloud connection |
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
+21 -1
View File
@@ -468,7 +468,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -497,6 +497,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
dws event stop <subscribe_id>
@@ -508,6 +527,7 @@ dws event stop <subscribe_id>
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
+403
View File
@@ -0,0 +1,403 @@
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
// lark-cli's scripts/fetch_meta.py.
//
// Usage:
//
// dws auth login # ensure valid auth
// make fetch-mcp-metadata # runs this tool
//
// The tool loads auth from the DWS keychain, iterates all 26 static server
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
// merges results, and writes schema_mcp_metadata.json.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// toolLister is the tools/list capability consumed by run; production code
// uses transport.Client, tests inject fakes.
type toolLister interface {
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
}
// Injection points so run() is fully testable without network/keychain/exit.
var (
osExit = os.Exit
getenv = os.Getenv
loadTokenData = auth.LoadTokenDataKeychain
staticServers = syncdata.StaticServers
registrySource = cli.EmbeddedCommandRegistryMergedJSON
listToolsTimeout = 30 * time.Second
gitHeadPath = ".git/HEAD"
newToolLister = func(token string) toolLister {
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
}
)
func main() {
osExit(run(os.Args[1:], os.Stderr))
}
func run(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
if err := flags.Parse(args); err != nil {
return 2
}
token := resolveToken(stderr)
if token == "" {
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
return 1
}
client := newToolLister(token)
// Iterate ALL static server endpoints (26 servers covering all products).
servers := staticServers()
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
// Load CLI registry to build tool_name → interface_ref mapping.
registryMap := loadRegistryInterfaceRefs(stderr)
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
// Load the previous schema_mcp_metadata.json to preserve hand-curated
// cross-server interface_ref mappings that automated matching can't derive.
prevData, prevErr := os.ReadFile(*output)
prevTools := map[string]map[string]any{}
if prevErr == nil {
var prev struct {
Tools map[string]map[string]any `json:"tools"`
}
if json.Unmarshal(prevData, &prev) == nil {
prevTools = prev.Tools
}
}
// Start from previous data (preserves cross-server refs), then overwrite
// with fresh MCP data where available.
allTools := make(map[string]map[string]any)
for k, v := range prevTools {
allTools[k] = v
}
// Reviewed cross-server interface_refs live only in the previous snapshot
// (the registry stores canonical paths, not MCP identities). Build a
// live-key → canonicals index so those tools get refreshed instead of
// being skipped and frozen at the previous snapshot forever.
crossRefs := buildCrossServerRefs(prevTools, registryMap)
if len(crossRefs) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
}
// Canonicals with a reviewed cross-server identity must only be fed by
// that identity; a same-named tool on another server is a coincidence,
// not a data source.
crossOwned := map[string]bool{}
for _, canonicals := range crossRefs {
for _, canonical := range canonicals {
crossOwned[canonical] = true
}
}
totalRaw := 0
failedServices := []string{}
for _, srv := range servers {
endpoint := strings.TrimSpace(srv.Endpoint)
if endpoint == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
result, err := client.ListTools(ctx, endpoint)
cancel()
if err != nil {
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
failedServices = append(failedServices, srv.ID)
continue
}
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
totalRaw += len(result.Tools)
for _, tool := range result.Tools {
name := strings.TrimSpace(tool.Name)
if name == "" {
continue
}
// Direct match: CLI canonical equals server-prefixed tool name
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
// here — their reviewed identity feeds them below.
canonicalKey := srv.ID + "." + name
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
}
// Cross-server match: registry canonicals whose reviewed
// interface_ref points at this live tool (one live tool may feed
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
for _, canonical := range crossRefs[canonicalKey] {
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
}
}
}
matched := 0
for _, t := range allTools {
if _, ok := t["interface_ref"]; ok {
matched++
}
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
// previous data, add stub entries (interface_ref only).
stubs := 0
for canonicalKey, ref := range registryMap {
if _, exists := allTools[canonicalKey]; exists {
continue
}
allTools[canonicalKey] = map[string]any{
"interface_ref": ref,
}
stubs++
}
if stubs > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
}
// Compute coverage fields required by check-schema-catalog.sh. Failed
// services must be reported honestly so policy can spot snapshot gaps.
if len(failedServices) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
len(failedServices), len(servers), strings.Join(failedServices, ", "))
}
metadata := map[string]any{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
"tools": allTools,
}
// source_revision: git commit hash (proves provenance).
if rev, err := os.ReadFile(gitHeadPath); err == nil {
metadata["source_revision"] = strings.TrimSpace(string(rev))
}
if err := writeMetadata(*output, metadata); err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
return 0
}
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
// fallback, the DWS keychain.
func resolveToken(stderr io.Writer) string {
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
if token != "" {
return token
}
td, err := loadTokenData()
if err != nil || td == nil || td.AccessToken == "" {
return ""
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
return td.AccessToken
}
// writeMetadata marshals the snapshot and writes it to the output path.
func writeMetadata(path string, metadata map[string]any) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write %s failed: %w", path, err)
}
return nil
}
// buildCoverage reports snapshot coverage honestly: snapshot_services only
// counts services whose tools/list succeeded, missing_services names the
// failures, and matched_tools excludes registry stubs (entries carrying no
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
missing := failedServices
if missing == nil {
missing = []string{}
}
return map[string]any{
"surface_scope": "source_revision",
"source_services": sourceServices,
"snapshot_services": sourceServices - len(missing),
"missing_services": missing,
"source_tools": sourceTools,
"surface_tools": surfaceTools,
"matched_tools": surfaceTools - stubs,
"aliased_tools": 0,
"unmatched_tools": stubs,
}
}
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
// a differently named product/RPC, so the previous cross-server mapping must
// survive even though title, description, and parameters are refreshed.
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
interfaceRef := any(fallbackRef)
if previous := allTools[canonicalKey]; previous != nil {
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
interfaceRef = reviewedRef
}
}
entry := map[string]any{
"title": tool.Title,
"description": tool.Description,
"interface_ref": interfaceRef,
}
if tool.InputSchema != nil {
entry["parameters"] = extractParams(tool.InputSchema)
}
allTools[canonicalKey] = entry
}
// buildCrossServerRefs indexes reviewed cross-server mappings from the
// previous snapshot: for every registry canonical whose interface_ref names a
// different MCP identity (product_id.rpc_name != canonical), the live key is
// mapped back to that canonical. One live tool may serve several canonicals,
// so values are slices, sorted for deterministic merge order.
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
index := map[string][]string{}
for canonical, entry := range prevTools {
if _, inRegistry := registryMap[canonical]; !inRegistry {
continue
}
ref, ok := entry["interface_ref"].(map[string]any)
if !ok {
continue
}
productID, _ := ref["product_id"].(string)
rpcName, _ := ref["rpc_name"].(string)
if productID == "" || rpcName == "" {
continue
}
liveKey := productID + "." + rpcName
if liveKey == canonical {
continue
}
index[liveKey] = append(index[liveKey], canonical)
}
for _, canonicals := range index {
sort.Strings(canonicals)
}
return index
}
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
// the cli package's reassembly API and builds a canonical_path →
// {product_id, rpc_name} mapping for interface_ref injection.
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
data, err := registrySource()
if err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
return map[string]map[string]string{}
}
var reg struct {
Products []struct {
ID string `json:"id"`
Tools []struct {
CanonicalPath string `json:"canonical_path"`
} `json:"tools"`
} `json:"products"`
}
if err := json.Unmarshal(data, &reg); err != nil {
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
return map[string]map[string]string{}
}
out := make(map[string]map[string]string)
for _, prod := range reg.Products {
for _, tool := range prod.Tools {
cp := strings.TrimSpace(tool.CanonicalPath)
if cp == "" || !strings.Contains(cp, ".") {
continue
}
parts := strings.SplitN(cp, ".", 2)
out[cp] = map[string]string{
"product_id": parts[0],
"rpc_name": parts[1],
}
}
}
return out
}
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
// the flat param-name → metadata map used by schema_mcp_metadata.json.
func extractParams(inputSchema map[string]any) map[string]map[string]any {
if inputSchema == nil {
return nil
}
properties, ok := inputSchema["properties"].(map[string]any)
if !ok {
return nil
}
requiredSet := map[string]bool{}
if req, ok := inputSchema["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
requiredSet[s] = true
}
}
}
params := make(map[string]map[string]any, len(properties))
for name, raw := range properties {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
meta := map[string]any{}
if t, ok := prop["type"].(string); ok {
meta["type"] = t
}
if d, ok := prop["description"].(string); ok {
meta["description"] = d
}
if d, ok := prop["default"].(string); ok {
meta["default"] = d
}
if e, ok := prop["enum"].([]any); ok {
enums := make([]string, 0, len(e))
for _, v := range e {
if s, ok := v.(string); ok {
enums = append(enums, s)
}
}
if len(enums) > 0 {
meta["enum"] = enums
}
}
meta["required"] = requiredSet[name]
params[name] = meta
}
return params
}
+557
View File
@@ -0,0 +1,557 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
var stderr bytes.Buffer
refs := loadRegistryInterfaceRefs(&stderr)
if len(refs) == 0 {
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
}
got, ok := refs["calendar.list_calendars"]
if !ok {
t.Fatal("calendar.list_calendars missing from reassembled split registry")
}
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
t.Fatalf("calendar.list_calendars ref = %#v", got)
}
}
func TestBuildCrossServerRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
}
prevTools := map[string]map[string]any{
// Fan-out: two canonicals share one live tool; insertion order must
// not affect the sorted result.
"aitable.advperm_enable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
"aitable.advperm_disable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
// Identity ref (live key == canonical) needs no cross entry.
"doc.copy_document": {
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
},
// Not in the registry: must be ignored.
"ghost.tool": {
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
},
}
got := buildCrossServerRefs(prevTools, registryMap)
want := map[string][]string{
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
}
if len(got) != len(want) {
t.Fatalf("index = %#v, want %#v", got, want)
}
for k, v := range want {
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
}
}
}
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"a.x": {"product_id": "a", "rpc_name": "x"},
"a.y": {"product_id": "a", "rpc_name": "y"},
"a.z": {"product_id": "a", "rpc_name": "z"},
}
prevTools := map[string]map[string]any{
"a.x": {"interface_ref": "not-a-map"},
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
"a.z": {"title": "no ref at all"},
}
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
t.Fatalf("index = %#v, want empty", got)
}
}
func TestRunRefreshesCrossServerTools(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
entry := snapshot.Tools[canonical]
if entry["title"] != "live title" || entry["description"] != "live desc" {
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
}
ref := entry["interface_ref"].(map[string]any)
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
}
}
}
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
// 数据源只能是评审身份指向的 live 工具。
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{
{ID: "aitable", Endpoint: "https://aitable.example"},
{ID: "aitable-helper", Endpoint: "https://helper.example"},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
// 同名巧合:aitable server 上恰好也有 advperm_enable。
"https://aitable.example": {Tools: []transport.ToolDescriptor{
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
}},
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
entry := snapshot.Tools["aitable.advperm_enable"]
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
}
}
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
const canonical = "calendar.list_calendars"
reviewedRef := map[string]any{
"product_id": "calendar-helper",
"rpc_name": "list_user_calendars",
}
allTools := map[string]map[string]any{
canonical: {
"title": "old title",
"description": "old description",
"interface_ref": reviewedRef,
"parameters": map[string]any{
"stale": map[string]any{"type": "string"},
},
},
}
live := transport.ToolDescriptor{
Name: "list_calendars",
Title: "new title",
Description: "new description",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"cursor": map[string]any{
"type": "string",
"description": "next page cursor",
},
},
"required": []any{"cursor"},
},
}
fallbackRef := map[string]string{
"product_id": "calendar",
"rpc_name": "list_calendars",
}
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
got := allTools[canonical]
if got["title"] != "new title" || got["description"] != "new description" {
t.Fatalf("live metadata was not refreshed: %#v", got)
}
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
}
params, ok := got["parameters"].(map[string]map[string]any)
if !ok {
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
}
if _, stale := params["stale"]; stale {
t.Fatalf("stale parameter survived refresh: %#v", params)
}
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
t.Fatalf("cursor parameter = %#v", cursor)
}
}
func TestBuildCoverageReportsFailedServices(t *testing.T) {
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
if got["source_services"] != 26 {
t.Fatalf("source_services = %v, want 26", got["source_services"])
}
if got["snapshot_services"] != 24 {
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
}
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
}
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
t.Fatalf("tool counts = %#v", got)
}
}
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
got := buildCoverage(26, nil, 813, 813, 0)
if got["snapshot_services"] != 26 {
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{}) {
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
}
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
}
}
// fakeLister returns canned tools/list results per endpoint.
type fakeLister struct {
results map[string]transport.ToolsListResult
errs map[string]error
}
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
if err := f.errs[endpoint]; err != nil {
return transport.ToolsListResult{}, err
}
return f.results[endpoint], nil
}
// stubDeps swaps every injection point for the duration of one test.
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
t.Helper()
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
t.Cleanup(func() {
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
})
getenv = func(key string) string {
if key == "DWS_ACCESS_TOKEN" {
return token
}
return ""
}
loadTokenData = keychain
staticServers = func() []syncdata.ServerInfo { return servers }
newToolLister = func(string) toolLister { return lister }
registrySource = registry
}
func testRegistryJSON() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
}
func TestRunNoTokenFails(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run(nil, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "no auth token") {
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
}
}
func TestRunInvalidFlagFails(t *testing.T) {
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
t.Fatalf("run() = %d, want 2", code)
}
}
func TestResolveTokenKeychainFallback(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) {
return &auth.TokenData{AccessToken: "kc-token"}, nil
}, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "kc-token" {
t.Fatalf("resolveToken() = %q, want kc-token", got)
}
if !strings.Contains(stderr.String(), "loaded token from keychain") {
t.Fatalf("stderr = %q, want keychain log", stderr.String())
}
}
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "" {
t.Fatalf("resolveToken() = %q, want empty", got)
}
}
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
servers := []syncdata.ServerInfo{
{ID: "doc", Endpoint: "https://doc.example"},
{ID: "sheet", Endpoint: "https://sheet.example"},
{ID: "blank", Endpoint: " "},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://doc.example": {Tools: []transport.ToolDescriptor{
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
"bogus": "not-a-map",
},
"required": []any{"doc_id", 42},
}},
{Name: " "},
{Name: "not_in_registry"},
}},
},
errs: map[string]error{"https://sheet.example": errors.New("boom")},
}
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
dir := t.TempDir()
output := filepath.Join(dir, "snapshot.json")
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Version int `json:"version"`
Coverage map[string]any `json:"coverage"`
Tools map[string]map[string]any
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.Version != 1 {
t.Fatalf("version = %d", snapshot.Version)
}
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
}
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
t.Fatalf("missing_services = %v, want [sheet]", got)
}
live := snapshot.Tools["doc.copy_document"]
if live == nil || live["title"] != "复制文档" {
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
}
params := live["parameters"].(map[string]any)
docID := params["doc_id"].(map[string]any)
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
t.Fatalf("doc_id = %#v", docID)
}
if enum := docID["enum"].([]any); len(enum) != 2 {
t.Fatalf("enum = %v, want the 2 string members only", enum)
}
if _, ok := params["bogus"]; ok {
t.Fatal("non-map property should be skipped")
}
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
if prevRef["product_id"] != "doc-helper" {
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
}
if _, ok := snapshot.Tools["not_in_registry"]; ok {
t.Fatal("tools outside the registry must be dropped")
}
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
}
}
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
output := filepath.Join(t.TempDir(), "snapshot.json")
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
}
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
output := filepath.Join(t.TempDir(), "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot load registry") {
t.Fatalf("stderr = %q, want registry warning", stderr.String())
}
}
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
var stderr bytes.Buffer
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
}
// 解析失败必须有告警,不得静默产出空映射。
if !strings.Contains(stderr.String(), "cannot parse registry") {
t.Fatalf("stderr = %q, want parse warning", stderr.String())
}
}
func TestRunWriteFailure(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1 on write failure", code)
}
}
func TestWriteMetadataMarshalFailure(t *testing.T) {
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
t.Fatalf("err = %v, want marshal failure", err)
}
}
func TestMainDelegatesToRun(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
origExit, origArgs := osExit, os.Args
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
exitCode := -1
osExit = func(code int) { exitCode = code }
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
main()
if exitCode != 0 {
t.Fatalf("main() exited with %d, want 0", exitCode)
}
}
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
if got := extractParams(nil); got != nil {
t.Fatalf("extractParams(nil) = %v, want nil", got)
}
if got := extractParams(map[string]any{"type": "object"}); got != nil {
t.Fatalf("extractParams(no properties) = %v, want nil", got)
}
}
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
if lister := newToolLister("tok"); lister == nil {
t.Fatal("newToolLister returned nil")
}
}
func TestRunRecordsSourceRevision(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
dir := t.TempDir()
head := filepath.Join(dir, "HEAD")
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
t.Fatal(err)
}
origHead := gitHeadPath
t.Cleanup(func() { gitHeadPath = origHead })
gitHeadPath = head
output := filepath.Join(dir, "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
SourceRevision string `json:"source_revision"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.SourceRevision != "ref: refs/heads/feature" {
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
}
}
+203
View File
@@ -0,0 +1,203 @@
# 5分钟搭好团队知识库:DWS 让 IT 服务台自己开口答疑,你值得拥有
> 💡 **省流版摘要:**
> 别再用鼠标一个个建文档了,真的没必要!本文带你用 DWS(钉钉命令行工具),5 分钟搭好一个团队知识库:建空间、搭目录、批量导入 FAQ、配权限,全程命令行一步到位。最后再花 30 秒把它挂到钉钉机器人上——同事在群里 @ 一下,知识库自己开口答疑。知识管理员从"搬运工"升级为"甩手掌柜",就是这么简单。
>
> (温馨提示:本文内容可以直接丢给智能体,让智能体一次性逐步安装和使用)
## 一、痛点吐槽:管知识库有多累?
身为企业 IT / 知识管理员,你肯定经历过这些"九九八十一难"……
- **点击马拉松**:建空间点 5 下、建文件夹点 3 下、建一篇文档再点 4 下。一个季度下来,鼠标点击次数比写的字还多。
- **搬运工噩梦**:几百篇历史 FAQ 散落在本地 Word / Markdown 里,要搬进钉钉知识库?复制粘贴到天荒地老,格式还经常翻车。
- **权限苦差**:新同事入职要加权限、转岗要改角色、离职要移除。逐个空间点进去操作,漏一个就是安全隐患。
- **知识沉睡**:库是建好了,可同事还是习惯私聊问你"VPN 又连不上了怎么办"。知识库躺着吃灰,你继续当人肉客服。😭
今天,DWS(DingTalk Workspace CLI)闪亮登场!🌟
你不需要写一行代码,只要在终端敲几行命令,知识库的"建、搬、管、用"全链路一次搞定。更香的是:搭好的知识库可以直接挂到钉钉机器人上,让知识自己开口答疑。
## 二、DWS 是个啥?知识库的"遥控指挥中心"
DWS 是钉钉能力的原子化封装,把复杂的 OpenAPI 打包成简单指令。管知识库这件事,主要靠它的"三驾马车":
| 命令族 | 能干什么 |
|---|---|
| 🗂️ `dws wiki` | 知识库空间、目录节点、成员权限的全生命周期管理 |
| 📄 `dws doc` | 文档内容读写、本地文件批量导入、模板套用 |
| 📁 `dws drive` | 钉盘文件上传下载、全局搜索、归档备份 |
你可以把它想象成知识库的"遥控指挥中心" 🎮——既能你手动按(终端敲命令,比点界面快 10 倍),也能让 AI 帮你按(Claude Code、Qoder 等智能体直接听懂并调用)。
**适合谁用:**
- **企业 IT / 知识管理员**:批量建库、批量导入、批量管权限,脚本化解放双手
- **开发者 / AI 玩家**:把知识库挂到机器人上,打造 24 小时答疑小能手
- **重度钉钉用户 / 效率党**:一条命令搜全库,比在界面里翻目录快得多
## 三、搭好的知识库能帮你做什么?
| 场景 | 玩法 |
|---|---|
| 🛟 IT 服务台 FAQ 库 | VPN、邮箱、打印机常见问题集中沉淀,机器人自动答疑 |
| 📜 制度流程库 | 报销、请假、采购制度批量导入,全文秒搜 |
| 🎓 新人上岗手册 | 按部门建目录,入职即授权限,自助通关 |
| 🤖 知识库 + 机器人 | `--knowledge-source wiki:<spaceId>` 一挂,群里 @ 它就答 |
所想即所得,拒绝画饼,直接上菜!🍽️
## 四、5分钟倒计时,搭好你的团队知识库
> 以下命令全部经过真实环境跑通验证,放心照抄。
### 0. 安装并登录 DWS(约 1 分钟)
把以下指令复制给你的智能体(Claude Code、Qoder、Codex 等)执行,或手动在终端跑:
macOS / Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows(PowerShell):
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
登录(提示授权请扫码):
```bash
dws auth login
```
【截图位:dws auth status 显示 token_valid: true】
### 1. 建一个知识库空间(10 秒)
```bash
dws wiki space create --name "IT服务台知识库" --desc "IT 常见问题与制度流程"
```
返回里的 `workspaceId` 就是空间的身份证号,后面每步都要用它。
【截图位:返回 workspaceId 与 spaceUrl】
### 2. 搭目录结构(20 秒)
知识库的结构 = 文件夹节点 + 文档节点。先建分类文件夹:
```bash
dws wiki node create --workspace <workspaceId> --name "常见问题FAQ" --type folder
dws wiki node create --workspace <workspaceId> --name "制度流程" --type folder
```
在文件夹下建一篇空文档(不加 `--folder` 就建在根目录):
```bash
dws wiki node create --workspace <workspaceId> --name "VPN连接失败排查指南" --folder <文件夹nodeId>
```
### 3. 批量导入历史文档(1 分钟,重头戏!)
几百篇本地 FAQ 不用复制粘贴,`doc import` 直接整批灌进知识库,Word、Excel、Markdown、txt 通吃:
```bash
# 单篇导入到指定文件夹
dws doc import --file ./vpn-faq.md --workspace <workspaceId> --folder <文件夹nodeId> --name "VPN连接失败排查指南"
# 批量导入整个目录(bash 一把梭)
for f in ./faq/*.md; do
dws doc import --file "$f" --workspace <workspaceId> --folder <文件夹nodeId>
done
```
已有在线文档想补内容?Markdown 直接写入:
```bash
dws doc update --node <文档nodeId> --content-file ./补充内容.md --mode append
```
【截图位:终端批量导入的滚动输出 + 知识库里齐刷刷的文档列表】
### 4. 配权限:把人拉进来(30 秒)
```bash
# 先用通讯录查到同事的 userId
dws contact user search --query "张三"
# 加为编辑者(--users 支持逗号分隔批量加)
dws wiki member add --workspace <workspaceId> --users <userId1>,<userId2> --role EDITOR
# 随时盘点成员
dws wiki member list --workspace <workspaceId>
```
### 5. 验收:搜一下,秒级命中(10 秒)
```bash
# 库内全文搜索
dws wiki node search --workspace <workspaceId> --query "VPN"
# 全局搜知识库空间
dws wiki space search --query "IT服务台"
```
【截图位:搜索结果命中文档标题】
### 6. 封神一步:挂到机器人上,知识自己开口答疑(30 秒)
如果你已经按《5分钟抱走你的嘴替机器人》建好了钉钉机器人,只需加一个参数:
```bash
dws dev connect --channel claudecode \
--robot-client-id <你的机器人ID> --robot-client-secret <你的机器人密钥> \
--knowledge-source wiki:<workspaceId>
```
机器人会自动从知识库拉取知识并缓存。同事在群里 @ 它问"VPN 连不上怎么办",它直接引用你刚导入的排查指南回答——你,终于不用当复读机了。😎
## 五、进阶使用技巧
### 知识库管理速查表
| 操作 | 命令 |
|---|---|
| 列出我的个人空间 | `dws wiki space list --type myWikiSpace` |
| 列出组织知识库 | `dws wiki space list --type orgWikiSpace` |
| 浏览库内节点树 | `dws wiki node list --workspace <ID> [--folder <nodeId>]` |
| 移动 / 复制节点 | `dws wiki node move` / `dws wiki node copy` |
| 改成员角色 | `dws wiki member update --users <UID> --role VIEWER` |
| 移除成员 | `dws wiki member remove --users <UID>` |
| 删除整个空间 | `dws wiki space delete --workspace <ID>`(进回收站,可恢复) |
### 老手避坑指南 ⛳
- 建在线表格用 `--type axls`,**`asheet` 服务端不支持**,别踩坑。
- `member list` 只返回姓名和角色、**不返回 userId**;要串联 `update` / `remove`,先用 `dws contact user search --query "<姓名>"` 反查。
- 搜索关键词的 flag 是 `--query`,`--keyword` 是遗留别名,新脚本请用 `--query`。
- 所有命令加 `--format json`,配合 `--jq` 过滤字段,写脚本时稳得一批。
- 破坏性操作(删空间、覆盖写文档)前加 `--dry-run` 先预览,确认无误再执行。
### 让机器人答得更好
| 开关 | 作用 |
|---|---|
| `--knowledge-source wiki:<spaceId>` | 从钉钉知识库拉知识作为答疑来源(本文主角) |
| `--knowledge-dir <目录>` | 挂本地 .md/.txt 知识目录,可与上面并存 |
| `--allowed-groups / --allowed-users` | 白名单,只让指定群或人触发 |
| `--daemon` | 后台常驻,关掉终端也不断线 |
## 六、更多 DWS 官方信息
- 钉钉 CLI 官网:https://open.dingtalk.com/dingtalk-cli
- 开源仓库:https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
- 上一篇姊妹篇:《5分钟抱走你的嘴替机器人:启动钉钉DWS,你值得拥有》
## 七、欢迎加入交流群
【截图位:DWS 交流群二维码】
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
+38
View File
@@ -5,6 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `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) |
@@ -12,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 / 描述 |
+8 -6
View File
@@ -8,7 +8,7 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog/`(`catalog.json` + 每产品 `tools/<product>.json`)和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
@@ -63,8 +63,9 @@ live Cobra flag facts / typed parameter metadata
build-time typed gates snapshot serializer
|
v
schema_catalog.json
(release output only)
schema_catalog/
(catalog.json + tools/<product>.json,
release output only)
|
v
go:embed -> typed loader
@@ -130,7 +131,7 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
@@ -272,7 +273,8 @@ dws schema --all # 所有工具的完整 leaf 导
- `internal/cli/schema_agent_metadata/index.json`
- `internal/cli/schema_agent_metadata/<product>.json`
- `internal/cli/schema_agent_metadata_audit.json`
- `internal/cli/schema_catalog.json`
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
@@ -303,7 +305,7 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
## 10. 明确禁止
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
// 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 (
"regexp"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// 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.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
return value, nil
}
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 be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+206
View File
@@ -0,0 +1,206 @@
// 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 TestParseAgentHost(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{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) {
got, err := parseAgentHost(tc.raw)
if err != nil {
t.Fatalf("parseAgentHost() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork_cloud\r"},
{name: "line feed", raw: "\nqwenwork_cloud"},
{name: "uppercase", raw: "Qwenwork_cloud"},
{name: "internal space", raw: "qwenwork cloud"},
{name: "internal tab", raw: "qwenwork\tcloud"},
{name: "unicode", raw: "千问办公"},
{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) {
got, err := parseAgentHost(tc.raw)
if err == nil {
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentHost() 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_host" {
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
}
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
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")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
mergeSawAgentHost := ""
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
mergeSawAgentHost = headers[headerDWSAgentHost]
headers["claw-type"] = "test-claw"
return headers
},
})
headers := resolveIdentityHeaders()
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
}
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
t.Fatalf("agentCode header = %q, want agent-code-test", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
if got := headers["claw-type"]; got != "test-claw" {
t.Fatalf("claw-type = %q, want test-claw", got)
}
}
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSChannel, "channel-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
return headers
},
})
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
t.Setenv(envDWSAgentHost, raw)
headers := resolveIdentityHeaders()
if _, ok := headers[headerDWSAgentHost]; ok {
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, 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_HOST")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST 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_host" {
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)
}
}
+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)
+137
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
@@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
}
}
func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
runner := &readOnlyDryRunRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
result, err := caller.(edition.ReadToolCaller).CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if err != nil {
t.Fatalf("CallReadTool() error = %v", err)
}
if got := runner.readCalls.Load(); got != 1 {
t.Fatalf("read calls = %d, want 1", got)
}
if got := runner.regularCalls.Load(); got != 0 {
t.Fatalf("regular calls = %d, want 0", got)
}
if runner.invocation.DryRun {
t.Fatal("read-only invocation was left in dry-run mode")
}
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) {
t.Fatalf("read result = %#v", result)
}
failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true})
if _, err := failClosed.(edition.ReadToolCaller).CallReadTool(
context.Background(), "im", "search_groups", nil,
); err == nil {
t.Fatal("runner without read-only capability was accepted")
}
}
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
var nilAdapter *toolCallerAdapter
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("nil adapter accepted a read-only call")
}
regularRunner := &capturingSuccessRunner{}
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
t.Fatalf("non-dry read should use the regular runner: %v", err)
}
if got := regularRunner.calls.Load(); got != 1 {
t.Fatalf("regular runner calls = %d, want 1", got)
}
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("read-only runner error was swallowed")
}
var nilRuntime *runtimeRunner
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("nil runtime runner accepted a read-only call")
}
}
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
fallback := &countingErrorRunner{}
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
@@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
}
}
func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) {
fallback := &capturingSuccessRunner{}
flags := &GlobalFlags{DryRun: true}
runner := &runtimeRunner{globalFlags: flags, fallback: fallback}
invocation := executor.NewHelperInvocation(
"test",
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil {
t.Fatalf("RunReadOnly() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("fallback calls = %d, want 1", got)
}
if fallback.invocation.DryRun {
t.Fatal("read-only fallback invocation was left in dry-run mode")
}
if !flags.DryRun {
t.Fatal("RunReadOnly mutated the process-wide dry-run flag")
}
if _, err := runner.Run(context.Background(), invocation); err != nil {
t.Fatalf("ordinary Run() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("ordinary dry-run reached fallback; calls = %d", got)
}
}
type countingErrorRunner struct {
calls atomic.Int64
}
@@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo
r.calls.Add(1)
return executor.Result{}, errors.New("runner must not be called")
}
type readOnlyDryRunRunner struct {
regularCalls atomic.Int64
readCalls atomic.Int64
invocation executor.Invocation
}
func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
r.regularCalls.Add(1)
return executor.Result{}, errors.New("regular runner must not be called")
}
func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.readCalls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type capturingSuccessRunner struct {
calls atomic.Int64
invocation executor.Invocation
}
func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type failingReadOnlyRunner struct{}
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("regular runner must not be called")
}
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("read failed")
}
+36 -8
View File
@@ -118,7 +118,7 @@ func newEventConsumeCommand() *cobra.Command {
)
cmd := &cobra.Command{
Use: "consume [event_key]",
Use: "consume [event_key...]",
Short: "订阅事件流并输出到 stdout",
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
@@ -138,9 +138,14 @@ func newEventConsumeCommand() *cobra.Command {
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览、确认后加
--yes,绝不要 kill -9。
可提供多个 event_key。多事件会各自创建订阅和本地 consumer,但共享同一 bus、
远程连接、输出和 duration/max-events。用户类事件必须共享一个 --user 或
--open-dingtalk-id,群类事件必须共享一个 --group;用户类与群类不能混用。
全部 consumer 就绪后 stderr 输出 [event] ready event_count=<n> bus_pid=<pid>。
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
通常不需要设置。`,
Args: cobra.MaximumNArgs(1),
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := eventNormalizeAs(asIdentity)
@@ -148,8 +153,17 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
if len(personalOpts.EventKeys) > 1 {
if err := rejectPersonalMultiEventFlags(c,
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
}
}
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
@@ -329,7 +343,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
"个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
@@ -337,13 +351,14 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
Type: "string",
Description: "要消费的个人事件码;省略时仅适用于显式配置其它事件来源的兼容模式",
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
Required: false,
Variadic: true,
Index: 0,
})
return cmd
@@ -777,7 +792,7 @@ func newEventStatusCommand() *cobra.Command {
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
@@ -1075,7 +1090,7 @@ func newEventStopCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
@@ -1192,6 +1207,19 @@ func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) erro
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
}
func rejectPersonalMultiEventFlags(c *cobra.Command, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
if f := c.Flags().Lookup(name); f != nil && f.Changed {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return nil
}
return fmt.Errorf("%s are not supported when consuming multiple events", strings.Join(changed, ", "))
}
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
+309 -2
View File
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
EventKeys []string
Flatten bool
DebugRawEvents bool
SubscribeID string
@@ -112,6 +113,7 @@ type personalStreamSourceOptions struct {
var (
personalResolveEventIdentity = resolvePersonalEventIdentity
personalLookupDefinition = personal.Lookup
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = (*personal.Client).GetSubscription
personalCreateSubscription = (*personal.Client).CreateSubscription
@@ -121,6 +123,7 @@ var (
personalRemoveRunStates = personal.RemoveRunStates
personalLoadRunStates = personal.LoadRunStates
personalConsumeRun = consume.Run
personalConsumeRunMany = consume.RunMany
personalValidateConsumeConfig = consume.ValidateConfig
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
personalNewStreamSource = newPersonalStreamSource
@@ -129,6 +132,7 @@ var (
personalQueryEntry = busctl.QueryEntry
personalQueryStatus = busctl.QueryStatus
personalStopBus = busctl.Stop
personalStopConsumers = busctl.StopConsumers
personalFindProcess = os.FindProcess
personalSignalProcess = (*os.Process).Signal
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
@@ -206,6 +210,21 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string, f
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" {
keys = []string{strings.TrimSpace(opts.EventKey)}
}
if len(keys) <= 1 {
if len(keys) == 1 {
opts.EventKey = keys[0]
}
return runPersonalEventConsumeSingle(c, opts)
}
opts.EventKeys = keys
return runPersonalEventConsumeMany(c, opts)
}
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
@@ -375,6 +394,266 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
type personalMultiSubscription struct {
Sub *personal.Subscription
EventKey string
RuleType string
}
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(false, opts.Flatten)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.DryRun {
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
if len(ids) > 0 {
if err := personalRemoveRunStates(workDir, ids); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err)
}
}
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
created = append(created, item)
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: id,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
}
}
defer cleanup()
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
specs = append(specs, consume.ConsumerSpec{
EventKey: item.EventKey,
EventTypes: []string{item.EventKey},
SubscribeID: item.Sub.SubscribeID,
ReadySubscribeID: item.Sub.SubscribeID,
})
}
if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil {
return err
}
return nil
}
func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
return nil, errors.New("--subscribe-id is not supported when consuming multiple events")
}
if strings.TrimSpace(opts.Rule) != "" {
return nil, errors.New("--rule is not supported when consuming multiple events")
}
if len(opts.Common.EventTypes) > 0 {
return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals")
}
if strings.TrimSpace(opts.Common.Filter) != "" {
return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals")
}
if opts.Common.Foreground || opts.Common.Force {
return nil, errors.New("--foreground/--force are not supported when consuming multiple events")
}
if opts.DebugRawEvents {
return nil, errors.New("--debug-raw-events is not supported when consuming multiple events")
}
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) < 2 {
return nil, errors.New("multiple event keys are required")
}
hasUserScope := false
hasGroupScope := false
for _, eventKey := range keys {
def, ok := personalLookupDefinition(eventKey)
if !ok {
return nil, fmt.Errorf("unknown personal event key %q", eventKey)
}
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
case "group":
hasGroupScope = true
}
if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) {
return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey)
}
}
if hasUserScope && hasGroupScope {
return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command")
}
userID := strings.TrimSpace(opts.UserID)
openID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
if userID != "" && openID != "" {
return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive")
}
switch {
case hasUserScope:
if groupID != "" {
return nil, errors.New("--group cannot be used with user-scoped events")
}
if userID == "" && openID == "" {
return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events")
}
case hasGroupScope:
if userID != "" || openID != "" {
return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events")
}
if groupID == "" {
return nil, errors.New("--group is required for the selected events")
}
default:
if userID != "" || openID != "" || groupID != "" {
return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group")
}
}
plans := make([]personalConsumeOptions, 0, len(keys))
for _, eventKey := range keys {
def, _ := personalLookupDefinition(eventKey)
plan := opts
plan.EventKey = eventKey
plan.EventKeys = nil
switch def.RuleType {
case "at", "all":
plan.UserID = ""
plan.OpenDingTalkID = ""
plan.GroupID = ""
case "singleChat", "sender":
plan.GroupID = ""
case "group":
plan.UserID = ""
plan.OpenDingTalkID = ""
}
if err := validatePersonalSubscriptionOptions(plan); err != nil {
return nil, err
}
plans = append(plans, plan)
}
return plans, nil
}
func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) {
preview := cfg
preview.EventTypes = make([]string, 0, len(plans))
for _, plan := range plans {
preview.EventTypes = append(preview.EventTypes, plan.EventKey)
}
consume.PrintDryRun(w, preview)
for i, plan := range plans {
ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{
UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID,
})
_, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV)
ruleJSON, _ := personal.CanonicalJSON(ruleParam)
fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s",
i, plan.EventKey, ruleType, ruleJSON)
if filter != "" {
fmt.Fprintf(w, " filter=%s", filter)
}
fmt.Fprintln(w)
}
}
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
@@ -648,7 +927,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
@@ -736,6 +1015,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
return nil
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
return err
} else {
fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err)
}
return interruptPersonalConsumers(ipcEndpoint, subscribeIDs)
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
@@ -968,6 +1258,23 @@ func firstNonEmptyPersonalString(values ...string) string {
return ""
}
func dedupePersonalEventKeys(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
@@ -993,7 +1300,7 @@ func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
return strings.TrimRight(config.DefaultMCPBaseURL, "/")
}
func configuredMCPBaseURL(configDir string) string {
+661
View File
@@ -0,0 +1,661 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/spf13/cobra"
)
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{
personal.EventMention,
personal.EventSingleChat,
personal.EventMention,
"--user", "test-user-001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := []string{personal.EventMention, personal.EventSingleChat}
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
}
}
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
tests := []struct {
name string
opts personalConsumeOptions
wantErr string
}{
{
name: "no target events",
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
},
{
name: "user and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
UserID: "test-user-001",
},
},
{
name: "group and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
GroupID: "cid-test",
},
},
{
name: "open dingtalk id",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
OpenDingTalkID: "open-test-user",
},
},
{
name: "user and group mixed",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
UserID: "test-user-001",
},
wantErr: "cannot be consumed in one command",
},
{
name: "duplicate keys collapse to one",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventMention},
},
wantErr: "multiple event keys are required",
},
{
name: "unknown event",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, "user_im_unknown"},
},
wantErr: "unknown personal event key",
},
{
name: "missing user target",
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
wantErr: "one of --user or --open-dingtalk-id",
},
{
name: "missing group target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
},
wantErr: "--group is required",
},
{
name: "user identity flags conflict",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
OpenDingTalkID: "open-test-user",
},
wantErr: "mutually exclusive",
},
{
name: "group target on user events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "--group cannot be used",
},
{
name: "user target on group events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "cannot be used with group-scoped events",
},
{
name: "target on no target events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
UserID: "test-user-001",
},
wantErr: "do not use --user",
},
{
name: "filter message events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
QueryCSV: "alarm",
},
},
{
name: "filter mixed with action",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
QueryCSV: "alarm",
},
wantErr: "require all selected events to be message receive events",
},
{
name: "invalid message filter",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
FilterJSON: "{",
},
wantErr: "filter",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plans, err := preparePersonalMultiOptions(test.opts)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
}
if len(plans) != len(test.opts.EventKeys) {
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
}
for _, plan := range plans {
def, _ := personal.Lookup(plan.EventKey)
if def.RuleType == "at" || def.RuleType == "all" {
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
t.Fatalf("no-target plan retained target: %#v", plan)
}
}
}
})
}
}
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
oldLookup := personalLookupDefinition
t.Cleanup(func() { personalLookupDefinition = oldLookup })
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
def, ok := personal.Lookup(eventKey)
if eventKey == personal.EventAllSingleChat {
def.Public = false
}
return def, ok
}
_, err := preparePersonalMultiOptions(personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
t.Fatalf("error = %v", err)
}
}
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
t.Fatalf("deduped keys = %#v", got)
}
}
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
opts := base
test.set(&opts)
if _, err := preparePersonalMultiOptions(opts); err == nil {
t.Fatal("option succeeded")
}
})
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
t.Fatal("personal consume ran after explicit multi-event flag")
return nil
}
flags := []string{
"--subscribe-id=",
"--rule=",
"--event-types=",
"--filter=",
"--foreground=false",
"--force=false",
"--debug-raw-events=false",
}
for _, flag := range flags {
t.Run(flag, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
t.Fatalf("Execute() error = %v", err)
}
})
}
}
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
createdKeys := make([]string, 0, 2)
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
createdKeys = append(createdKeys, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
}
for i, spec := range specs {
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
t.Fatalf("spec[%d] = %#v", i, spec)
}
}
return nil
}
cmd := newPersonalCoverageCommand()
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
Flatten: true,
})
if err != nil {
t.Fatalf("runPersonalEventConsume() error = %v", err)
}
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called after partial creation failure")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
upsertErr error
wantErr string
}{
{
name: "nil subscription",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
wantErr: "empty subscription",
},
{
name: "empty subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
wantErr: "empty subscribe_id",
},
{
name: "duplicate subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-duplicate"}
},
wantErr: "duplicate subscribe_id",
},
{
name: "run state write failure",
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
},
upsertErr: errors.New("state write failed"),
wantErr: "save run state",
},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
return test.ensure(calls, opts), opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called with invalid subscription result")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
t.Fatal("dry-run created a subscription")
return nil, "", "", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
QueryCSV: "alarm",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "subscription[0]") ||
!strings.Contains(stderr.String(), "subscription[1]") ||
!strings.Contains(stderr.String(), "filter=") {
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
t.Run("prepare error", func(t *testing.T) {
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention},
})
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
t.Fatalf("error = %v", err)
}
})
t.Run("format warning and identity error", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "bogus"); err != nil {
t.Fatal(err)
}
wantErr := errors.New("identity")
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{}, wantErr
}
opts := valid
opts.Common.FormatRaw = "bogus"
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !strings.Contains(stderr.String(), "using ndjson") {
t.Fatalf("warning = %q", stderr.String())
}
})
t.Run("flatten raw conflict", func(t *testing.T) {
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("format", "raw"); err != nil {
t.Fatal(err)
}
opts := valid
opts.Flatten = true
opts.Common.FormatRaw = "raw"
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v", err)
}
})
t.Run("route validation and output conflict", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
opts := valid
opts.Common.RoutesRaw = []string{"bad-route"}
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
t.Fatal("invalid route succeeded")
}
wantErr := errors.New("validate")
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
t.Fatalf("config validation error = %v", err)
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("output", "events.json"); err != nil {
t.Fatal(err)
}
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
t.Fatalf("output conflict error = %v", err)
}
})
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cleanupErr := errors.New("cleanup")
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
runErr := errors.New("run many")
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v", err)
}
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
t.Fatalf("cleanup warnings = %q", stderr.String())
}
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
oldSignal := personalSignalProcess
defer func() {
personalStopConsumers = oldStop
personalQueryStatus = oldQuery
personalFindProcess = oldFind
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried after targeted stop succeeded")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
}
proc := &os.Process{}
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
signals := 0
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
var warning bytes.Buffer
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
t.Fatalf("signals=%d warning=%q", signals, warning.String())
}
wantErr := errors.New("targeted stop transport failed")
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, wantErr
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy fallback ran for a non-compatibility error")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
t.Fatalf("transport error = %v", err)
}
}
func installPersonalManySeams(t *testing.T) func() {
t.Helper()
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRunMany = oldRunMany
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
}
}
+173
View File
@@ -47,12 +47,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
assertPersonalOutputHidesSchemaIDs(t, got)
for _, eventKey := range []string{
personal.EventFromUser,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
personal.EventReadO2O,
personal.EventReadGroup,
personal.EventRecallO2O,
personal.EventRecallGroup,
personal.EventReactionO2O,
personal.EventReactionGroup,
personal.EventGroupUpdated,
personal.EventGroupMemberAdded,
personal.EventGroupMemberExited,
personal.EventGroupDisbanded,
} {
if !strings.Contains(got, eventKey) {
t.Fatalf("list output missing %s: %s", eventKey, got)
@@ -230,6 +236,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
personal.EventMention,
personal.EventSingleChat,
personal.EventInChat,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
@@ -265,6 +273,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
"message_id",
"create_time",
"event_time",
"quoted_message",
"forward_messages",
} {
if !strings.Contains(got, want) {
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
@@ -309,6 +319,103 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
}
quoted, ok := props["quoted_message"].(map[string]any)
if !ok || quoted["type"] != "object" {
t.Fatalf("schema.properties.quoted_message = %#v, want object", props["quoted_message"])
}
forward, ok := props["forward_messages"].(map[string]any)
if !ok || forward["type"] != "array" {
t.Fatalf("schema.properties.forward_messages = %#v, want array", props["forward_messages"])
}
})
}
}
func TestPersonalGroupLifecycleEventSchemaUsesConservativePayload(t *testing.T) {
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
required, ok := doc["required_params"].([]any)
if !ok || len(required) != 1 || required[0] != "group" {
t.Fatalf("required_params = %#v, want [group]", doc["required_params"])
}
schema := doc["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if len(properties) != 5 {
t.Fatalf("schema.properties = %#v, want five conservative fields", properties)
}
payload, ok := properties["payload"].(map[string]any)
if !ok || payload["type"] != "object" || payload["additionalProperties"] != true {
t.Fatalf("schema.properties.payload = %#v", properties["payload"])
}
})
}
}
func TestPersonalGroupMemberEventSchemaMatchesFlatOutput(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
"operator", "operator_open_dingtalk_id", "members", "event_time",
}
for _, eventKey := range []string{personal.EventGroupMemberAdded, personal.EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
properties := doc["schema"].(map[string]any)["properties"].(map[string]any)
if len(properties) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", properties, len(wantProperties))
}
for _, field := range wantProperties {
if _, ok := properties[field].(map[string]any); !ok {
t.Fatalf("schema missing %q: %#v", field, properties)
}
}
members := properties["members"].(map[string]any)
items, ok := members["items"].(map[string]any)
if !ok || members["type"] != "array" || items["type"] != "object" {
t.Fatalf("members schema = %#v", members)
}
memberProperties, ok := items["properties"].(map[string]any)
if !ok {
t.Fatalf("members.items.properties = %#v", items["properties"])
}
for _, field := range []string{"nick", "open_dingtalk_id"} {
if _, ok := memberProperties[field].(map[string]any); !ok {
t.Fatalf("member schema missing %q: %#v", field, memberProperties)
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("group member schema exposed generic payload: %#v", properties)
}
})
}
}
@@ -485,6 +592,64 @@ func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
}
}
func TestPersonalNewIMEventsDryRunAndValidation(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
for _, test := range []struct {
eventKey string
args []string
}{
{eventKey: personal.EventAllSingleChat},
{eventKey: personal.EventAllGroupChat},
{eventKey: personal.EventGroupUpdated, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberAdded, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberExited, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupDisbanded, args: []string{"--group", "cid-test-group"}},
} {
t.Run(test.eventKey, func(t *testing.T) {
if err := ensurePublicPersonalEvent(test.eventKey); err != nil {
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(append([]string{test.eventKey}, append(test.args, "--dry-run")...))
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run Execute() error = %v", err)
}
})
}
for _, eventKey := range []string{personal.EventAllSingleChat, personal.EventAllGroupChat} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--user", "test-user-001", "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
t.Fatalf("%s scoped user error = %v", eventKey, err)
}
}
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupMemberAdded, personal.EventGroupMemberExited, personal.EventGroupDisbanded} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("%s missing group error = %v", eventKey, err)
}
}
}
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
root := NewRootCommand()
root.SilenceUsage = true
@@ -550,6 +715,14 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
}
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
positionals, ok := doc["positionals"].([]any)
if !ok || len(positionals) != 1 {
t.Fatalf("schema positionals = %#v", doc["positionals"])
}
eventKey, ok := positionals[0].(map[string]any)
if !ok || eventKey["name"] != "event_key" || eventKey["variadic"] != true {
t.Fatalf("event_key positional = %#v, want variadic", positionals[0])
}
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
@@ -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)
}
+11
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,6 +354,16 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// 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).
if flags.ClientID != "" {
+2
View File
@@ -5,6 +5,7 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -41,6 +42,7 @@ func configureRootHelp(root *cobra.Command) {
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
return
}
renderRootHelp(root)
+1 -1
View File
@@ -380,7 +380,7 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
for _, flag := range []string{"sender", "senders", "sender-ids"} {
for _, flag := range []string{"sender", "senders", "sender-ids", "message-type", "only-robot", "conversation-type"} {
if searchAdvanced.Flags().Lookup(flag) == nil {
t.Fatalf("chat message search-advanced missing --%s", flag)
}
+44 -4
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"
)
@@ -224,6 +225,24 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.runSingle(ctx, invocation, true)
}
// RunReadOnly executes one already-classified read lookup for a semantic
// Shortcut that is building a dry-run plan. It clones the runtime flags and
// clears DryRun only on that clone: the process-wide caller and every ordinary
// ToolCaller invocation retain the global execution barrier.
func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
if r == nil {
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
}
clone := *r
if r.globalFlags != nil {
flags := *r.globalFlags
flags.DryRun = false
clone.globalFlags = &flags
}
invocation.DryRun = false
return clone.Run(ctx, invocation)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
@@ -986,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)
@@ -1038,12 +1057,33 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// 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.
if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" {
headers[headerDWSAgentHost] = agentHost
}
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
}
@@ -0,0 +1,410 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 210
)
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
for _, candidate := range shortcut.All() {
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
continue
}
public = append(public, candidate)
}
if got := len(public); got != publicShortcutCount {
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutCount)
}
deliveredShortcuts := 0
for canonical := range tools {
if strings.Contains(canonical, ".shortcut_") {
deliveredShortcuts++
}
}
if deliveredShortcuts != schemaPublishedShortcutCount {
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
}
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excludedPaths := make(map[string]bool, len(exclusions))
for _, exclusion := range exclusions {
if !exclusion.Reviewed || strings.TrimSpace(exclusion.Reason) == "" {
t.Fatalf("unreviewed public command exclusion: %#v", exclusion)
}
excludedPaths[exclusion.CLIPath] = true
}
excludedShortcuts := 0
for _, declared := range public {
declared := declared
t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) {
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
cliPath := declared.Service + " " + declared.Command
if !excludedPaths[cliPath] {
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
}
excludedShortcuts++
return
}
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
})
}
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
}
}
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
}
if got, want := schemaContractString(leaf["confirmation"]), "not_required"; got != want {
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
}
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
if required, _ := conversationID["required"].(bool); !required {
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
}
if got := leaf["constraints"]; got != nil {
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
}
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
wantConstraints := map[string]any{
"require_one_of": [][]string{{"users", "rooms"}},
}
if got := constrainedLeaf["constraints"]; !schemaContractJSONEqual(got, wantConstraints) {
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantConstraints)
}
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 124; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
shortcutCount := 0
for _, summary := range summaries {
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs(append([]string{"schema"}, append(args, "--format", "json")...))
if err := root.Execute(); err != nil {
t.Fatalf("execute dws schema %q: %v; stderr=%s", strings.Join(args, " "), err, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode dws schema %q: %v", strings.Join(args, " "), err)
}
return payload
}
func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
name := strings.ReplaceAll(strings.TrimPrefix(declared.Command, "+"), "-", "_")
return declared.Service + ".shortcut_" + name
}
func assertEmbeddedShortcutIdentityAndSelection(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
if got, want := schemaContractString(tool["canonical_path"]), canonical; got != want {
t.Errorf("canonical_path = %q, want %q", got, want)
}
if got, want := schemaContractString(tool["primary_cli_path"]), declared.Service+" "+declared.Command; got != want {
t.Errorf("%s primary_cli_path = %q, want %q", canonical, got, want)
}
if got, want := schemaContractString(tool["agent_summary"]), declared.Description; got != want {
t.Errorf("%s agent_summary = %q, want %q", canonical, got, want)
}
if got, want := schemaContractStringSlice(tool["use_when"]), []string{declared.Intent}; !schemaContractJSONEqual(got, want) {
t.Errorf("%s use_when = %#v, want %#v", canonical, got, want)
}
if len(schemaContractStringSlice(tool["avoid_when"])) == 0 {
t.Errorf("%s has no reviewed avoid_when", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 || len(examples) > 2 {
t.Errorf("%s examples = %d, want 1..2", canonical, len(examples))
}
for _, example := range examples {
if strings.Contains(example, "--yes") {
t.Errorf("%s stores unsafe example %q", canonical, example)
}
if !strings.HasPrefix(example, "dws "+declared.Service+" "+declared.Command) {
t.Errorf("%s example does not use its primary path: %q", canonical, example)
}
}
}
func assertEmbeddedShortcutSafetyAndInterface(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
"confirmation": wantConfirmation,
"idempotency": wantIdempotency,
"interface_mode": "composite",
"availability": "available",
} {
if got := schemaContractString(tool[field]); got != want {
t.Errorf("%s %s = %q, want %q", canonical, field, got, want)
}
}
if strings.TrimSpace(schemaContractString(tool["interface_reason"])) == "" {
t.Errorf("%s has no reviewed composite interface reason", canonical)
}
}
func assertEmbeddedShortcutParameters(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
parameters := schemaContractMap(tool["parameters"])
publicFlags := make([]shortcut.Flag, 0, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
}
}
if got, want := len(parameters), len(publicFlags); got != want {
t.Errorf("%s parameters = %d, want %d", canonical, got, want)
}
for _, flag := range publicFlags {
parameter := parameters[flag.Name]
if parameter == nil {
t.Errorf("%s is missing parameter --%s", canonical, flag.Name)
continue
}
flagType := flag.Type
if flagType == "" {
flagType = shortcut.FlagString
}
wantType := map[shortcut.FlagType]string{
shortcut.FlagString: "string",
shortcut.FlagBool: "boolean",
shortcut.FlagInt: "integer",
shortcut.FlagStringSlice: "array",
}[flagType]
if got := schemaContractString(parameter["type"]); got != wantType {
t.Errorf("%s --%s type = %q, want %q", canonical, flag.Name, got, wantType)
}
if got, _ := parameter["required"].(bool); got != shortcutSchemaRequired(declared, flag.Name) {
t.Errorf("%s --%s required = %t, want %t", canonical, flag.Name, got, shortcutSchemaRequired(declared, flag.Name))
}
if got, want := schemaContractString(parameter["default"]), shortcutSchemaDefault(flag); got != want {
t.Errorf("%s --%s default = %q, want %q", canonical, flag.Name, got, want)
}
gotEnum := schemaContractStringSlice(parameter["enum"])
if len(flag.Enum) == 0 {
if len(gotEnum) != 0 {
t.Errorf("%s --%s enum = %#v, want empty", canonical, flag.Name, gotEnum)
}
} else if !schemaContractJSONEqual(gotEnum, flag.Enum) {
t.Errorf("%s --%s enum = %#v, want %#v", canonical, flag.Name, gotEnum, flag.Enum)
}
}
}
func shortcutSchemaDefault(flag shortcut.Flag) string {
value := strings.TrimSpace(flag.Default)
switch flag.Type {
case shortcut.FlagBool:
if value != "true" {
return ""
}
case shortcut.FlagInt:
if value == "0" {
return ""
}
case shortcut.FlagStringSlice:
if value != "" {
return "[" + value + "]"
}
}
return value
}
func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
for _, flag := range declared.Flags {
if flag.Name == flagName && flag.Required {
return true
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
public[flag.Name] = true
}
}
for _, constraint := range declared.Constraints {
if constraint.Kind != shortcut.ConstraintAtLeastOne && constraint.Kind != shortcut.ConstraintExactlyOne {
continue
}
visible := make([]string, 0, len(constraint.Flags))
for _, constrained := range constraint.Flags {
if public[constrained] {
visible = append(visible, constrained)
}
}
if len(visible) == 1 && visible[0] == flagName {
return true
}
}
return false
}
func assertEmbeddedShortcutConstraints(
t testing.TB,
tool map[string]any,
declared shortcut.Shortcut,
canonical string,
) {
t.Helper()
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
if !flag.Hidden {
public[flag.Name] = true
}
}
want := map[string][][]string{}
for _, constraint := range declared.Constraints {
flags := make([]string, 0, len(constraint.Flags))
for _, flagName := range constraint.Flags {
if public[flagName] {
flags = append(flags, flagName)
}
}
switch constraint.Kind {
case shortcut.ConstraintAtLeastOne:
if len(flags) > 1 {
want["require_one_of"] = append(want["require_one_of"], flags)
}
case shortcut.ConstraintExactlyOne:
if len(flags) > 1 {
want["require_one_of"] = append(want["require_one_of"], flags)
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
}
case shortcut.ConstraintMutuallyExclusive:
if len(flags) > 1 {
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
}
case shortcut.ConstraintCustom:
for _, flagName := range flags {
description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"])
for _, evidence := range shortcutCustomConstraintEvidence(constraint.Description) {
if !strings.Contains(description, evidence) {
t.Errorf("%s --%s description does not publish custom constraint evidence %q: %q", canonical, flagName, evidence, description)
}
}
}
default:
t.Errorf("%s has unsupported declared shortcut constraint %q", canonical, constraint.Kind)
}
}
if len(want) == 0 {
if got := tool["constraints"]; got != nil {
t.Errorf("%s constraints = %#v, want omitted", canonical, got)
}
return
}
if got := tool["constraints"]; !schemaContractJSONEqual(got, want) {
t.Errorf("%s constraints = %s, want %s", canonical, mustShortcutJSON(got), mustShortcutJSON(want))
}
}
func shortcutCustomConstraintEvidence(description string) []string {
// Custom constraints are prose rather than a typed wire contract. Require
// their decision-relevant facts to survive in the delivered parameter
// description while allowing the renderer to reorder connective wording.
probes := []string{
"原文=>替换",
"不能为空",
"不能重复",
"大于 0",
"工作目录",
"相对路径",
"绝对路径",
"..",
"最多 15 个字符",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
if strings.Contains(description, probe) {
evidence = append(evidence, probe)
}
}
if len(evidence) > 0 {
return evidence
}
return []string{strings.TrimSpace(description)}
}
func mustShortcutJSON(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(encoded)
}
+27
View File
@@ -66,6 +66,33 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
return convertResult(result), nil
}
type dryRunReadRunner interface {
RunReadOnly(context.Context, executor.Invocation) (executor.Result, error)
}
// CallReadTool executes a Shortcut's explicitly classified read lookup while
// the outer command is in dry-run mode. Ordinary CallTool remains protected by
// the global execution barrier. The runner capability is optional and fails
// closed so an injected runner cannot accidentally receive a real call.
func (a *toolCallerAdapter) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
if a == nil || a.runner == nil {
return nil, fmt.Errorf("ToolCaller runner is not configured")
}
if !a.DryRun() {
return a.CallTool(ctx, productID, toolName, args)
}
readRunner, ok := a.runner.(dryRunReadRunner)
if !ok {
return nil, fmt.Errorf("ToolCaller runner does not support read-only dry-run lookups")
}
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
result, err := readRunner.RunReadOnly(ctx, inv)
if err != nil {
return nil, err
}
return convertResult(result), nil
}
// CallToolWithToken invokes a helper with an in-memory token override. It is
// used during login before the new token has been persisted to any profile
// slot.
+13
View File
@@ -39,6 +39,19 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
return res, err
}
func (r recordingToolCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
inner, ok := r.inner.(edition.ReadToolCaller)
if !ok {
return nil, fmt.Errorf("ToolCaller read-only dry-run lookup is not configured")
}
recordedArgs := cloneToolArgs(args)
res, err := inner.CallReadTool(ctx, product, tool, args)
// This is a real read even though the outer command is a dry-run. Record it
// as such so usage evidence does not misclassify the lookup as skipped.
usage.Append(product, tool, recordedArgs, err == nil, false)
return res, err
}
func (r recordingToolCaller) CallToolWithToken(
ctx context.Context,
token, product, tool string,
+56
View File
@@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct {
args map[string]any
token string
dryRun bool
reads int
}
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
@@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken(
return &edition.ToolResult{}, nil
}
func (c *crossPlatformCoverageCaller) CallReadTool(
_ context.Context,
_, _ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.reads++
c.args = args
return &edition.ToolResult{}, nil
}
func (*crossPlatformCoverageCaller) Format() string { return "json" }
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
@@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv("DWS_USAGE_TRACKING", "1")
inner := &crossPlatformCoverageCaller{dryRun: true}
caller := newRecordingToolCaller(inner)
readCaller, ok := caller.(edition.ReadToolCaller)
if !ok {
t.Fatal("recording caller dropped read-only capability")
}
if _, err := readCaller.CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"open_conversation_id": "cid_x"},
); err != nil {
t.Fatal(err)
}
if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" {
t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args)
}
records, err := usage.Read()
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || !records[0].OK {
t.Fatalf("real read must be recorded as successful: %#v", records)
}
withoutRead := recordingToolCaller{inner: nonReadToolCaller{}}
if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("recording caller accepted an inner caller without read support")
}
}
type nonReadToolCaller struct{}
func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (nonReadToolCaller) Format() string { return "json" }
func (nonReadToolCaller) DryRun() bool { return false }
func (nonReadToolCaller) Fields() string { return "" }
func (nonReadToolCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
+135
View File
@@ -0,0 +1,135 @@
// 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.
// command_meta.go provides the unified metadata consumption API. All runtime
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
// to get a CommandMeta struct — one function, one struct, no need to know which
// of the 6 generation layers a field comes from.
//
// This is the "simple consumption" half of the generation/consumption split:
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
package cli
import (
"sort"
"strings"
"sync"
)
// CommandMeta is the complete runtime metadata view for a single command.
// Consumers read this struct; they never touch the raw catalog maps.
type CommandMeta struct {
Identity CommandIdentity
Safety CommandSafety
Selection CommandSelection
}
// CommandIdentity is the stable identity of a command.
type CommandIdentity struct {
CLIPath string // "dev app delete"
Canonical string // "dev.delete_dev_app"
Aliases []string // ["search", ...]
ProductID string // "devapp"
Title string // one-line description
}
// CommandSelection is the agent-facing selection metadata.
type CommandSelection struct {
AgentSummary string
UseWhen []string
AvoidWhen []string
Examples []string
}
var (
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
)
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
func initMetaByCLIPath() {
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
}
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
lookup := make(map[string]CommandMeta)
if loaded.Snapshot.Tools == nil {
return lookup
}
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
for _, tool := range loaded.Snapshot.Tools {
cliPath := schemaString(tool["cli_path"])
if cliPath == "" {
continue
}
meta := CommandMeta{
Identity: CommandIdentity{
CLIPath: cliPath,
Canonical: schemaString(tool["canonical_path"]),
Aliases: schemaStringSlice(tool["aliases"]),
ProductID: schemaString(tool["product_id"]),
Title: schemaString(tool["title"]),
},
Safety: CommandSafety{
Effect: schemaString(tool["effect"]),
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
},
Selection: CommandSelection{
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
Examples: schemaStringSlice(tool["examples"]),
},
}
lookup[cliPath] = meta
metas = append(metas, meta)
}
// Register compat alias paths (e.g. "report list") against the same
// metadata in a second pass, sorted by primary cli_path: primary paths
// always win (registered above, aliases only fill vacancies), and an
// alias-vs-alias collision resolves deterministically to the owner with
// the lexicographically smallest primary path — Snapshot.Tools is a map,
// so relying on iteration order would make the winner vary per process.
sort.Slice(metas, func(i, j int) bool {
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
})
for _, meta := range metas {
for _, alias := range meta.Identity.Aliases {
alias = strings.TrimSpace(alias)
if alias == "" || alias == meta.Identity.CLIPath {
continue
}
if _, exists := lookup[alias]; !exists {
lookup[alias] = meta
}
}
}
return lookup
}
// ResolveMeta returns the complete metadata for a command identified by its CLI
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
// for "report inbox list"). Returns ok=false for commands not in the embedded
// catalog (utility commands, hidden commands, shortcuts).
func ResolveMeta(cliPath string) (CommandMeta, bool) {
metaByCLIPathOnce.Do(initMetaByCLIPath)
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
return m, ok
}
+77
View File
@@ -0,0 +1,77 @@
// 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 cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// CommandSafety holds the safety metadata for a CLI command, resolved at
// runtime from the embedded schema catalog. This is a read-only view over the
// catalog — NOT a second safety source. The catalog remains the single
// authoritative reviewed source; this struct merely provides typed access for
// consumers (help rendering, skill generation).
type CommandSafety struct {
Effect string // read / write / destructive
Risk string // low / medium / high
Confirmation string // not_required / user_required
Idempotency string // idempotent / non_idempotent
}
// ShouldRender returns true when the safety metadata warrants a visible
// annotation in --help. Only commands that need confirmation or carry
// above-low risk are annotated; read-only low-risk commands stay clean.
func (s CommandSafety) ShouldRender() bool {
return s.Confirmation == "user_required" ||
(s.Risk != "" && s.Risk != "low")
}
// SafetyForCLIPath returns the safety metadata for a command identified by its
// CLI path (e.g. "dev app delete"). Returns ok=false when the command is not
// in the embedded catalog (utility commands, hidden commands, shortcuts).
//
// Deprecated: use ResolveMeta(cliPath).Safety for the complete metadata view.
// Kept for backward compatibility with existing callers.
func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
meta, ok := ResolveMeta(cliPath)
if !ok {
return CommandSafety{}, false
}
return meta.Safety, true
}
// RenderSafetyAnnotation writes a "Safety:" line to the command's stdout when
// the command carries above-low risk or requires user confirmation. This is
// the shared entry point for ALL help rendering paths (root HelpFunc, product
// group custom HelpFuncs like calendar's). It avoids the timing issue where a
// group captures origHelp before configureRootHelp sets the root's custom func.
func RenderSafetyAnnotation(cmd *cobra.Command) {
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
safety, ok := SafetyForCLIPath(cliPath)
if !ok || !safety.ShouldRender() {
return
}
w := cmd.OutOrStdout()
fmt.Fprintf(w, "\nSafety: effect=%s risk=%s confirmation=%s", safety.Effect, safety.Risk, safety.Confirmation)
if safety.Idempotency != "" {
fmt.Fprintf(w, " idempotency=%s", safety.Idempotency)
}
if safety.Confirmation == "user_required" {
fmt.Fprint(w, " (requires --yes)")
}
fmt.Fprintln(w)
}
+132
View File
@@ -0,0 +1,132 @@
// 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 cli
import (
"testing"
)
func TestSafetyForCLIPathKnownCommand(t *testing.T) {
// dev app delete 是 destructive + high risk + user_required。
s, ok := SafetyForCLIPath("dev app delete")
if !ok {
t.Fatal("SafetyForCLIPath(\"dev app delete\") returned ok=false; want true")
}
if s.Effect != "destructive" {
t.Errorf("effect = %q, want destructive", s.Effect)
}
if s.Risk != "high" {
t.Errorf("risk = %q, want high", s.Risk)
}
if s.Confirmation != "user_required" {
t.Errorf("confirmation = %q, want user_required", s.Confirmation)
}
if !s.ShouldRender() {
t.Error("ShouldRender() = false for destructive/high/user_required; want true")
}
}
func TestSafetyForCLIPathUnknownSkips(t *testing.T) {
// 不存在的命令 → ok=false。
_, ok := SafetyForCLIPath("nonexistent fake command")
if ok {
t.Fatal("SafetyForCLIPath for nonexistent command returned ok=true; want false")
}
}
func TestSafetyForCLIPathReadOnlyLowRiskSkips(t *testing.T) {
// 找一个 read/low 风险命令,ShouldRender 应 false。
// dev app list 是 read,默认 low risk。
s, ok := SafetyForCLIPath("dev app list")
if !ok {
t.Skip("dev app list not in catalog; skipping")
}
if s.ShouldRender() {
t.Errorf("ShouldRender() = true for effect=%s risk=%s; want false (read/low)", s.Effect, s.Risk)
}
}
func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
// 带首尾空格的 cli_path 也能查到。
s1, _ := SafetyForCLIPath("dev app delete")
s2, ok2 := SafetyForCLIPath(" dev app delete ")
if !ok2 {
t.Fatal("trimmed lookup failed; whitespace not trimmed")
}
if s1 != s2 {
t.Errorf("whitespace-trimmed result differs: %+v vs %+v", s1, s2)
}
}
func TestResolveMetaComplete(t *testing.T) {
// dev app delete: destructive + high + user_required + 有 selection。
m, ok := ResolveMeta("dev app delete")
if !ok {
t.Fatal("ResolveMeta(\"dev app delete\") returned ok=false")
}
if m.Identity.CLIPath != "dev app delete" {
t.Errorf("CLIPath = %q", m.Identity.CLIPath)
}
if m.Safety.Effect != "destructive" {
t.Errorf("Effect = %q, want destructive", m.Safety.Effect)
}
if len(m.Selection.UseWhen) == 0 && m.Selection.AgentSummary == "" {
t.Error("Selection is empty; expected use_when or agent_summary")
}
t.Logf("dev app delete: canonical=%s safety=%+v selection(use_when=%d avoid_when=%d examples=%d)",
m.Identity.Canonical, m.Safety, len(m.Selection.UseWhen), len(m.Selection.AvoidWhen), len(m.Selection.Examples))
}
func TestResolveMetaUnknownSkips(t *testing.T) {
_, ok := ResolveMeta("nonexistent fake command")
if ok {
t.Fatal("ResolveMeta for unknown command returned ok=true")
}
}
func TestResolveMetaCopiesAliases(t *testing.T) {
// report inbox list 在 Catalog 中声明了兼容别名 "report list"。
m, ok := ResolveMeta("report inbox list")
if !ok {
t.Fatal("ResolveMeta(\"report inbox list\") returned ok=false")
}
found := false
for _, alias := range m.Identity.Aliases {
if alias == "report list" {
found = true
break
}
}
if !found {
t.Fatalf("Identity.Aliases = %v, want to contain \"report list\"", m.Identity.Aliases)
}
}
func TestResolveMetaAliasLookup(t *testing.T) {
// 兼容别名路径必须解析到与主 cli_path 同一份元数据。
primary, ok := ResolveMeta("report inbox list")
if !ok {
t.Fatal("primary path lookup failed")
}
aliased, ok := ResolveMeta("report list")
if !ok {
t.Fatal("ResolveMeta(\"report list\") alias lookup returned ok=false")
}
if aliased.Identity.CLIPath != primary.Identity.CLIPath || aliased.Identity.Canonical != primary.Identity.Canonical {
t.Fatalf("alias metadata = %+v, want same identity as primary %+v", aliased.Identity, primary.Identity)
}
if aliased.Safety != primary.Safety {
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
}
}
+38
View File
@@ -0,0 +1,38 @@
// 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.
// gen.go is the single entry point for schema metadata generation. It isolates
// all //go:generate pragmas from business code so that:
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
// - Generation is a standalone process (make generate-schema triggers this).
// - The 6-input → 1-output contract is documented in one place.
//
// Generation inputs (authored, reviewed):
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
// 2. schema_hints/metadata/*.json safety (effect/risk/confirmation)
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
// 4. schema_mcp_metadata.json MCP server tool definitions
// 5. schema_parameter_bindings.json parameter type/property mappings
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// Generation outputs (embedded at build):
// - schema_agent_metadata/*.json per-product agent metadata
// - schema_catalog/ per-product catalog shards
package cli
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
// package cached by the preceding metadata generator with the old embedded
// JSON files.
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
-6
View File
@@ -22,12 +22,6 @@ import (
"sync/atomic"
)
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry.json -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
// package cached by the preceding metadata generator with the old embedded
// JSON files.
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog.json
//go:embed schema_agent_metadata/*.json
var embeddedAgentMetadataFS embed.FS
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
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
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -2,7 +2,7 @@
"product_id": "event",
"tools": {
"event consume": {
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -14,18 +14,18 @@
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"field_provenance": {
"agent_summary": {
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -106,7 +106,7 @@
"examples": {
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -116,7 +116,7 @@
{
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -231,8 +231,11 @@
"use_when": {
"value": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -242,8 +245,11 @@
{
"value": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -270,8 +276,11 @@
],
"use_when": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
]
},
"event list": {
@@ -763,7 +772,7 @@
},
"use_when": {
"value": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -772,7 +781,7 @@
"candidates": [
{
"value": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -797,7 +806,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
]
},
"event status": {
+88 -19
View File
@@ -1,19 +1,19 @@
{
"version": 1,
"source_hash": "sha256:bda812c0b48f221a70f9c710d56a4dead5916bf425a9b153e7b275d15365f1a0",
"surface_hash": "sha256:8eef4235b4391a6a865c180ebd3a65ca3531a84c6985b37811f23fff9daf2e3a",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"coverage": {
"surface_products": 25,
"products_with_metadata": 25,
"surface_tools": 603,
"tools_with_metadata": 603,
"tools_with_agent_summary": 603,
"tools_with_use_when": 603,
"tools_with_avoid_when": 603,
"tools_with_examples": 603,
"tools_with_interface_mode": 603,
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
"surface_products": 26,
"products_with_metadata": 26,
"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
},
"products": {
"aisearch": {
@@ -585,6 +585,74 @@
"创建/配置开放平台应用、机器人、权限、事件订阅或发布版本"
]
},
"devapp": {
"agent_summary": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
"agent_summary_source": "dws-agent-selection/devapp",
"avoid_when": [
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
],
"field_provenance": {
"agent_summary": {
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"avoid_when": {
"value": [
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
],
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
],
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"use_when": {
"value": [
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
],
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
],
"source": "internal/cli/schema_hints/selection/devapp.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
}
},
"source_refs": [
"CommandRegistry:product=devapp",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_hints/selection/devapp.json",
"internal/shortcut/devapp"
],
"use_when": [
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
]
},
"devdoc": {
"agent_summary": "搜索钉钉开放平台开发文档与错误排查资料",
"agent_summary_source": "dws-agent-selection/devdoc",
@@ -870,20 +938,20 @@
]
},
"event": {
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
],
"field_provenance": {
"agent_summary": {
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true
@@ -910,7 +978,7 @@
},
"use_when": {
"value": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -918,7 +986,7 @@
"candidates": [
{
"value": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -938,7 +1006,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
]
},
"hrbrain": {
@@ -1806,6 +1874,7 @@
"chat",
"contact",
"dev",
"devapp",
"devdoc",
"ding",
"doc",
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,525 @@
{
"product_id": "report",
"tools": {
"report +inbox-list": {
"agent_summary": "列出我收到的日报(按时间范围分页)",
"agent_summary_source": "dws-agent-selection/report",
"availability": "available",
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
],
"field_provenance": {
"agent_summary": {
"value": "列出我收到的日报(按时间范围分页)",
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "列出我收到的日报(按时间范围分页)",
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"avoid_when": {
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"examples": {
"value": [
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_reason": {
"value": "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.",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "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.",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": false,
"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."
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"use_when": {
"value": [
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"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,
"risk": "low",
"source_refs": [
"ShortcutRegistry:report +inbox-list",
"cobra-help:dws report +inbox-list",
"internal/cli/schema_command_registry.json#report.shortcut_inbox_list",
"internal/cli/schema_hints/metadata/report.json",
"internal/cli/schema_hints/selection/report.json"
],
"use_when": [
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
]
},
"report +outbox-list": {
"agent_summary": "列出我发出的日报(可选时间/模版名过滤)",
"agent_summary_source": "dws-agent-selection/report",
"availability": "available",
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws report +outbox-list --cursor 0 --size 20",
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
],
"field_provenance": {
"agent_summary": {
"value": "列出我发出的日报(可选时间/模版名过滤)",
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "列出我发出的日报(可选时间/模版名过滤)",
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"avoid_when": {
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"examples": {
"value": [
"dws report +outbox-list --cursor 0 --size 20",
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"dws report +outbox-list --cursor 0 --size 20",
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_reason": {
"value": "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.",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "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.",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": false,
"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."
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"use_when": {
"value": [
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
],
"source": "internal/cli/schema_hints/selection/report.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"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,
"risk": "low",
"source_refs": [
"ShortcutRegistry:report +outbox-list",
"cobra-help:dws report +outbox-list",
"internal/cli/schema_command_registry.json#report.shortcut_outbox_list",
"internal/cli/schema_hints/metadata/report.json",
"internal/cli/schema_hints/selection/report.json"
],
"use_when": [
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
]
},
"report entry get": {
"agent_summary": "获取指定一篇日志的详情信息",
"agent_summary_source": "dws-agent-selection/report",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,264 @@
{
"product_id": "wiki",
"tools": {
"wiki +space-search": {
"agent_summary": "搜索知识库",
"agent_summary_source": "dws-agent-selection/wiki",
"availability": "available",
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws wiki +space-search --query \"产品文档\""
],
"field_provenance": {
"agent_summary": {
"value": "搜索知识库",
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "搜索知识库",
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"avoid_when": {
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"examples": {
"value": [
"dws wiki +space-search --query \"产品文档\""
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"dws wiki +space-search --query \"产品文档\""
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_reason": {
"value": "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.",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "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.",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": false,
"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."
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
},
"use_when": {
"value": [
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"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.",
"candidates": [
{
"value": [
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": 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."
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"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,
"risk": "low",
"source_refs": [
"ShortcutRegistry:wiki +space-search",
"cobra-help:dws wiki +space-search",
"internal/cli/schema_command_registry.json#wiki.shortcut_space_search",
"internal/cli/schema_hints/metadata/wiki.json",
"internal/cli/schema_hints/selection/wiki.json"
],
"use_when": [
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
]
},
"wiki member add": {
"agent_summary": "为指定知识库添加一个或多个成员,并授予指定角色",
"agent_summary_source": "dws-agent-selection/wiki",
File diff suppressed because it is too large Load Diff
+86 -4
View File
@@ -15,10 +15,11 @@ package cli
import (
"crypto/sha256"
_ "embed"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"strings"
"sync"
"sync/atomic"
@@ -29,8 +30,20 @@ import (
const SchemaCatalogSnapshotVersion = 1
//go:embed schema_catalog.json
var embeddedSchemaCatalogJSON []byte
// The release Catalog is committed as a per-product split so concurrent
// feature PRs only rewrite the shard for the product they touch:
//
// schema_catalog/catalog.json global envelope + Catalog map
// schema_catalog/tools/<product>.json that product's leaf ToolSpecs
//
// The loader reassembles the exact same SchemaCatalogSnapshot, so the
// source_hash integrity check is identical to the single-file layout.
//
//go:embed schema_catalog/catalog.json
var embeddedSchemaCatalogEnvelopeJSON []byte
//go:embed schema_catalog/tools
var embeddedSchemaCatalogTools embed.FS
// SchemaCatalogSnapshot is the release-stable Agent contract. Catalog holds
// the progressive product/tool index; Tools holds full leaf parameter schemas.
@@ -68,11 +81,80 @@ var runtimeEmbeddedSchemaCatalogLazyLoadCount atomic.Uint64
func embeddedSchemaCatalog() loadedSchemaCatalog {
runtimeEmbeddedSchemaCatalogOnce.Do(func() {
runtimeEmbeddedSchemaCatalogLazyLoadCount.Add(1)
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = decodeSchemaCatalogSnapshot(embeddedSchemaCatalogJSON)
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = assembleEmbeddedSchemaCatalog()
})
return runtimeEmbeddedSchemaCatalog
}
// schemaCatalogEnvelope is the global half of the split release Catalog. It
// mirrors the generator's envelope struct; the Catalog map and release hashes
// do not partition by product and stay in one file.
type schemaCatalogEnvelope struct {
Version int `json:"version"`
SurfaceHash string `json:"surface_hash,omitempty"`
SourceHash string `json:"source_hash"`
Catalog map[string]any `json:"catalog"`
}
// schemaCatalogToolShard mirrors the per-product shard written by the
// generator. Only the product and its leaf ToolSpecs are stored here.
type schemaCatalogToolShard struct {
Product string `json:"product"`
Tools map[string]map[string]any `json:"tools"`
}
// assembleEmbeddedSchemaCatalog reassembles the split release Catalog shards
// into the same SchemaCatalogSnapshot the single-file layout produced, then
// validates it through the production loader. source_hash still covers the
// whole payload: if any shard is missing, stale, or tampered, the content hash
// check in loadSchemaCatalogSnapshot fails exactly as before.
func assembleEmbeddedSchemaCatalog() (loadedSchemaCatalog, error) {
snapshot, err := assembleSchemaCatalogSnapshot(embeddedSchemaCatalogEnvelopeJSON, embeddedSchemaCatalogTools, "schema_catalog/tools")
if err != nil {
return loadedSchemaCatalog{}, err
}
return loadSchemaCatalogSnapshot(snapshot)
}
// assembleSchemaCatalogSnapshot merges an envelope document and a directory of
// per-product tool shards back into the single-document snapshot shape. Split
// from assembleEmbeddedSchemaCatalog so shard failure modes stay testable
// against fake filesystems.
func assembleSchemaCatalogSnapshot(envelopeJSON []byte, shards fs.FS, dir string) (SchemaCatalogSnapshot, error) {
var envelope schemaCatalogEnvelope
if err := decodeStrictSchemaJSON(envelopeJSON, &envelope); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog.json: %w", err)
}
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err)
}
tools := make(map[string]map[string]any, len(entries)*8)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
if readErr != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr)
}
var shard schemaCatalogToolShard
if err := decodeStrictSchemaJSON(data, &shard); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err)
}
for canonical, spec := range shard.Tools {
tools[canonical] = spec
}
}
return SchemaCatalogSnapshot{
Version: envelope.Version,
SurfaceHash: envelope.SurfaceHash,
SourceHash: envelope.SourceHash,
Catalog: envelope.Catalog,
Tools: tools,
}, nil
}
func embeddedSchemaCatalogError() error {
_ = embeddedSchemaCatalog()
return runtimeEmbeddedSchemaCatalogErr
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,762 @@
{
"product": "devdoc",
"tools": {
"devdoc.search_open_platform_docs_rag": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:canonical_path=devdoc.search_open_platform_docs_rag",
"Skill:skills/mono/references/products/devdoc.md",
"cobra-help:dws devdoc article search",
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.devdoc.prod.json#devdoc.search_open_platform_docs",
"internal/cli/schema_command_registry.json#devdoc.search_open_platform_docs_rag",
"internal/cli/schema_hints/imported/wukong.json",
"internal/cli/schema_hints/metadata/devdoc.json",
"internal/cli/schema_hints/selection/devdoc.json",
"internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag",
"live-dws-schema:devdoc.search_open_platform_docs_rag",
"skills/mono/references/products/devdoc.md",
"skills/mono/references/products/simple.md",
"structured-hint:internal/cli/schema_hints/imported/wukong.json#devdoc.search_open_platform_docs_rag",
"structured-hint:internal/cli/schema_hints/selection-review.json#devdoc.search_open_platform_docs_rag"
],
"agent_summary": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
"agent_summary_source": "dws-agent-selection/devdoc",
"availability": "available",
"avoid_when": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
],
"canonical_path": "devdoc.search_open_platform_docs_rag",
"cli_name": "search",
"cli_path": "devdoc article search",
"confirmation": "not_required",
"constraints": {
"require_one_of": [
[
"query",
"keyword"
]
]
},
"description": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。",
"display": "开放平台文档搜索",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "available"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
},
{
"precedence": "reviewed_manual",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": false,
"source": "reviewed_manual_hint",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "not_required"
},
{
"precedence": "inference_or_default",
"selected": false,
"source": "risk-default",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "根据关键词搜索钉钉开放平台的开发文档,返回匹配的文档条目列表,包含标题、摘要、文档链接和相关标签。搜索结果按相关性排序。适用于开发者在集成或调试过程中快速查找 API 说明、接入指南、错误码解释等技术资料。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
},
"effect": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "command-verb",
"value": "read"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "command-verb",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "idempotent"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "mcp"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "mcp"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "mcp"
},
"interface_ref": {
"candidates": [
{
"precedence": "imported",
"selected": true,
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
}
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "devdoc.search_open_platform_docs"
}
],
"precedence": "imported",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
}
},
"metadata_source": {
"candidates": [
{
"precedence": "derived_resolution",
"selected": true,
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
}
],
"precedence": "derived_resolution",
"resolution": "highest_precedence",
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": false,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "low"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "搜索开放平台文档"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "search_open_platform_docs"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "搜索开放平台文档"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
},
"group": "article",
"has_parameters": true,
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
},
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "search_open_platform_docs_rag",
"parameter_count": 3,
"parameters": {
"page": {
"default": "1",
"description": "页码,默认 1",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "页码,默认 1"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "分页页码"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "页码,默认 1"
},
"interface_type": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": "number"
},
{
"precedence": "fallback",
"selected": false,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": "number"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "page"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "page"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "分页页码",
"interface_type": "number",
"property": "page",
"required": false,
"type": "string"
},
"query": {
"description": "搜索关键词 (必填)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "搜索关键词 (必填)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "搜索关键词"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "搜索关键词 (必填)"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "keyword"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "query"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "keyword"
},
"required": {
"candidates": [
{
"precedence": "reviewed_manual",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "reviewed_manual_hint",
"value": false
},
{
"precedence": "command_constraint",
"selected": false,
"source": "require_one_of_constraint",
"value": false
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": true
},
{
"precedence": "inference",
"selected": false,
"source": "usage_required_inference",
"value": true
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "reviewed_manual",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "reviewed_manual_hint",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "搜索关键词",
"property": "keyword",
"required": false,
"type": "string"
},
"size": {
"default": "10",
"description": "每页数量,默认 10",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "每页数量,默认 10"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "分页大小"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "每页数量,默认 10"
},
"interface_type": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": "number"
},
{
"precedence": "fallback",
"selected": false,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": "number"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "size"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "size"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "分页大小",
"interface_type": "number",
"property": "size",
"required": false,
"type": "string"
}
},
"path": "devdoc.search_open_platform_docs_rag",
"positionals": [
{
"description": "搜索关键词;也可通过 --query 传入",
"index": 0,
"name": "keyword",
"required": false,
"type": "string"
}
],
"primary_cli_path": "devdoc article search",
"product_id": "devdoc",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "搜索开放平台文档",
"use_when": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
}
}
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
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
{
"product": "live",
"tools": {
"live.get_my_lives": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:canonical_path=live.get_my_lives",
"Skill:skills/multi/dingtalk-live/SKILL.md",
"cobra-help:dws live stream list",
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.live.prod.json#live.get_my_lives",
"internal/cli/schema_command_registry.json#live.get_my_lives",
"internal/cli/schema_hints/imported/wukong.json",
"internal/cli/schema_hints/metadata/live.json",
"internal/cli/schema_hints/selection/live.json",
"internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives",
"live-dws-schema:live.get_my_lives",
"skills/mono/references/products/live.md",
"structured-hint:internal/cli/schema_hints/imported/wukong.json#live.get_my_lives",
"structured-hint:internal/cli/schema_hints/selection-review.json#live.get_my_lives"
],
"agent_summary": "查看当前用户发起的直播列表与基础统计",
"agent_summary_source": "dws-agent-selection/live",
"availability": "available",
"avoid_when": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
],
"canonical_path": "live.get_my_lives",
"cli_name": "list",
"cli_path": "live stream list",
"confirmation": "not_required",
"description": "查看我的直播列表",
"display": "直播列表 / 信息",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws live stream list"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": "查看当前用户发起的直播列表与基础统计"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": "查看当前用户发起的直播列表与基础统计"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "available"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "live stream list",
"value": "live.get_my_lives"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "live stream list",
"value": "live.get_my_lives"
},
"confirmation": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "risk-default",
"value": "not_required"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "risk-default",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查看我的直播列表"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "获取当前用户发起的直播列表,包含每场直播的基础信息与统计数据。返回字段包括:直播标题、封面图 URL、直播 ID、主播昵称、开始/结束时间戳、播放时长(毫秒)、观看量(UV)及直播状态(如已结束)。数据按分页返回,支持判断是否还有更多内容。适用于个人直播管理、历史记录查看等场景,仅展示当前用户有权限访问的直播内容。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查看我的直播列表"
},
"effect": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "command-verb",
"value": "read"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "command-verb",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"dws live stream list"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"dws live stream list"
]
},
"idempotency": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "idempotent"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "mcp"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "mcp"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "mcp"
},
"interface_ref": {
"candidates": [
{
"precedence": "imported",
"selected": true,
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "live",
"rpc_name": "get_my_lives"
}
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "live.get_my_lives"
}
],
"precedence": "imported",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "live",
"rpc_name": "get_my_lives"
}
},
"metadata_source": {
"candidates": [
{
"precedence": "derived_resolution",
"selected": true,
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
}
],
"precedence": "derived_resolution",
"resolution": "highest_precedence",
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": false,
"source": "internal/cli/schema_hints/selection/live.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "low"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查看我的直播列表"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "获取当前用户发起的直播列表"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查看我的直播列表"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
},
"group": "stream",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "live",
"rpc_name": "get_my_lives"
},
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "get_my_lives",
"parameter_count": 0,
"parameters": {},
"path": "live.get_my_lives",
"primary_cli_path": "live stream list",
"product_id": "live",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "查看我的直播列表",
"use_when": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
{
"product": "mcp",
"tools": {
"mcp.url_get": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws mcp url get --help",
"internal/app/mcp_url_command.go",
"internal/cli/schema_command_registry.json#mcp.url_get",
"internal/cli/schema_hints/metadata/mcp.json",
"internal/cli/schema_hints/selection/mcp.json",
"pkg/edition/default.go#openSupplementServers"
],
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"canonical_path": "mcp.url_get",
"cli_name": "get",
"cli_path": "mcp url get",
"confirmation": "not_required",
"description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。",
"display": "管理 MCP 服务连接信息",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws mcp url get 10043 --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
},
"interface_ref": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": null
}
],
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": false,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
},
"group": "url",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"is_alias": false,
"name": "url_get",
"parameter_count": 0,
"parameters": {},
"path": "mcp.url_get",
"positionals": [
{
"description": "钉钉 MCP 市场中的 mcpId",
"index": 0,
"name": "mcp_id",
"required": true,
"type": "string"
}
],
"primary_cli_path": "mcp url get",
"product_id": "mcp",
"reviewed": true,
"risk": "medium",
"source": "reviewed_command_registry",
"title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
}
}
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
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
+373
View File
@@ -0,0 +1,373 @@
// 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 cli
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// schema_catalog.json is generated, but every command entry it delivers to
// Agents must follow one unified closed structure: a fixed required core plus
// a whitelisted optional set. Any new field must be registered here first,
// which keeps the delivered command data structure uniform across products.
// Command identity (names/paths) is owned by schema_command_registry.json and
// is deliberately out of scope for this validator.
const schemaCatalogStructureMaxViolations = 25
// schemaCatalogToolRequiredKeys is the required core present on every tool
// entry. Keep sorted; ValidateCatalogStructure rejects entries missing any of
// these.
var schemaCatalogToolRequiredKeys = []string{
"agent_metadata_source",
"agent_source_refs",
"agent_summary",
"agent_summary_source",
"availability",
"avoid_when",
"canonical_path",
"cli_name",
"cli_path",
"confirmation",
"description",
"display",
"effect",
"effect_source",
"examples",
"field_provenance",
"has_parameters",
"idempotency",
"interface_mode",
"is_alias",
"name",
"parameter_count",
"parameters",
"path",
"primary_cli_path",
"product_id",
"reviewed",
"risk",
"source",
"title",
"use_when",
}
// schemaCatalogToolOptionalKeys is the optional whitelist. interface_ref and
// interface_reason are mutually exclusive and gated by interface_mode.
var schemaCatalogToolOptionalKeys = []string{
"aliases",
"constraints",
"dry_run",
"group",
"interface_reason",
"interface_ref",
"metadata_source",
"positionals",
}
var schemaCatalogToolEnums = map[string][]string{
"effect": {"read", "write", "destructive"},
"risk": {"low", "medium", "high"},
"confirmation": {"not_required", "user_required"},
"interface_mode": {InterfaceModeMCP, InterfaceModeComposite, InterfaceModeLocal},
"availability": {InterfaceAvailable, InterfaceUnavailable},
}
// schemaCatalogParamRequiredKeys is the required core of every parameter.
var schemaCatalogParamRequiredKeys = []string{
"description",
"field_provenance",
"required",
"type",
}
// schemaCatalogParamOptionalKeys is the parameter optional whitelist.
var schemaCatalogParamOptionalKeys = []string{
"cli_required",
"default",
"enum",
"example",
"format",
"interface_description",
"interface_type",
"property",
"required_when",
}
var schemaCatalogParamTypes = []string{"string", "integer", "number", "boolean", "array", "object"}
type schemaCatalogStructureViolation struct {
tool string
message string
}
// ValidateCatalogStructure checks that every tool entry in a schema_catalog
// snapshot conforms to the unified command data structure. It returns an
// error aggregating up to schemaCatalogStructureMaxViolations violations.
func ValidateCatalogStructure(data []byte) error {
var snapshot struct {
Version int `json:"version"`
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
return fmt.Errorf("decode schema catalog: %w", err)
}
if snapshot.Version != SchemaCatalogSnapshotVersion {
return fmt.Errorf("schema catalog version = %d, want %d", snapshot.Version, SchemaCatalogSnapshotVersion)
}
if len(snapshot.Tools) == 0 {
return fmt.Errorf("schema catalog has no tools")
}
violations := []schemaCatalogStructureViolation{}
for toolID, entry := range snapshot.Tools {
validateCatalogToolEntry(toolID, entry, &violations)
}
if len(violations) == 0 {
return nil
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].tool != violations[j].tool {
return violations[i].tool < violations[j].tool
}
return violations[i].message < violations[j].message
})
var b strings.Builder
shown := violations
truncated := 0
if len(shown) > schemaCatalogStructureMaxViolations {
truncated = len(shown) - schemaCatalogStructureMaxViolations
shown = shown[:schemaCatalogStructureMaxViolations]
}
for _, v := range shown {
fmt.Fprintf(&b, "\n %s: %s", v.tool, v.message)
}
if truncated > 0 {
fmt.Fprintf(&b, "\n ... and %d more violations", truncated)
}
return fmt.Errorf("schema catalog entries violate the unified command structure (%d total):%s", len(violations), b.String())
}
func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
allowed := make(map[string]bool, len(schemaCatalogToolRequiredKeys)+len(schemaCatalogToolOptionalKeys))
for _, k := range schemaCatalogToolRequiredKeys {
allowed[k] = true
}
for _, k := range schemaCatalogToolOptionalKeys {
allowed[k] = true
}
for key := range entry {
if !allowed[key] {
report("unknown field %q (register it in schema_catalog_structure.go first)", key)
}
}
stringKeys := []string{
"agent_metadata_source", "agent_summary", "agent_summary_source",
"availability", "canonical_path", "cli_name", "cli_path", "confirmation",
"description", "display", "effect", "effect_source", "idempotency",
"interface_mode", "name", "path", "primary_cli_path", "product_id",
"risk", "source", "title",
}
for _, key := range stringKeys {
if !requireNonEmptyString(entry, key) {
report("field %q must be a non-empty string", key)
}
}
stringArrayKeys := []string{"agent_source_refs", "avoid_when", "examples", "use_when"}
for _, key := range stringArrayKeys {
if !requireStringArray(entry, key) {
report("field %q must be an array of strings", key)
}
}
for _, key := range []string{"has_parameters", "is_alias", "reviewed"} {
if _, ok := entry[key].(bool); !ok {
report("field %q must be a boolean", key)
}
}
if _, ok := entry["field_provenance"].(map[string]any); !ok {
report("field %q must be an object", "field_provenance")
}
for field, values := range schemaCatalogToolEnums {
raw, ok := entry[field].(string)
if !ok {
continue // already reported as missing/typed above
}
if !stringSliceContains(values, raw) {
report("field %q = %q, want one of {%s}", field, raw, strings.Join(values, ", "))
}
}
parameters, paramsOK := entry["parameters"].(map[string]any)
if !paramsOK {
report("field %q must be an object", "parameters")
parameters = nil
}
count, countOK := entry["parameter_count"].(float64)
if !countOK {
report("field %q must be a number", "parameter_count")
} else if paramsOK && int(count) != len(parameters) {
report("parameter_count = %d, want %d (len(parameters))", int(count), len(parameters))
}
if has, ok := entry["has_parameters"].(bool); ok && paramsOK && has != (len(parameters) > 0) {
report("has_parameters = %v, want %v (len(parameters) > 0)", has, len(parameters) > 0)
}
validateCatalogInterface(toolID, entry, violations)
for paramName, raw := range parameters {
param, ok := raw.(map[string]any)
if !ok {
report("parameter %q must be an object", paramName)
continue
}
validateCatalogParam(toolID, paramName, param, violations)
}
}
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
mode, _ := entry["interface_mode"].(string)
ref, hasRef := entry["interface_ref"]
reason, _ := entry["interface_reason"].(string)
switch mode {
case InterfaceModeMCP:
if !hasRef {
report("interface_mode=mcp requires interface_ref")
return
}
refObj, ok := ref.(map[string]any)
if !ok {
report("interface_ref must be an object")
return
}
for _, key := range []string{"product_id", "rpc_name"} {
if s, ok := refObj[key].(string); !ok || strings.TrimSpace(s) == "" {
report("interface_ref.%s must be a non-empty string", key)
}
}
if strings.TrimSpace(reason) != "" {
report("interface_mode=mcp must not set interface_reason")
}
case InterfaceModeComposite, InterfaceModeLocal:
if hasRef {
report("interface_mode=%s must not set interface_ref", mode)
}
if strings.TrimSpace(reason) == "" {
report("interface_mode=%s requires a non-empty interface_reason", mode)
}
}
}
func validateCatalogParam(toolID, paramName string, param map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
allowed := make(map[string]bool, len(schemaCatalogParamRequiredKeys)+len(schemaCatalogParamOptionalKeys))
for _, k := range schemaCatalogParamRequiredKeys {
allowed[k] = true
}
for _, k := range schemaCatalogParamOptionalKeys {
allowed[k] = true
}
for key := range param {
if !allowed[key] {
report("parameter %q: unknown field %q", paramName, key)
}
}
typ, _ := param["type"].(string)
if !stringSliceContains(schemaCatalogParamTypes, typ) {
report("parameter %q: type = %q, want one of {%s}", paramName, typ, strings.Join(schemaCatalogParamTypes, ", "))
}
if s, ok := param["description"].(string); !ok || strings.TrimSpace(s) == "" {
report("parameter %q: description must be a non-empty string", paramName)
}
if _, ok := param["required"].(bool); !ok {
report("parameter %q: required must be a boolean", paramName)
}
if _, ok := param["field_provenance"].(map[string]any); !ok {
report("parameter %q: field_provenance must be an object", paramName)
}
for _, key := range []string{"property", "interface_description", "interface_type", "format", "default", "example", "required_when"} {
if raw, present := param[key]; present {
if _, ok := raw.(string); !ok {
report("parameter %q: %s must be a string", paramName, key)
}
}
}
if raw, present := param["cli_required"]; present {
if _, ok := raw.(bool); !ok {
report("parameter %q: cli_required must be a boolean", paramName)
}
}
if raw, present := param["enum"]; present {
items, ok := raw.([]any)
if !ok {
report("parameter %q: enum must be an array", paramName)
} else {
for i, item := range items {
if _, ok := item.(string); !ok {
report("parameter %q: enum[%d] must be a string", paramName, i)
}
}
}
}
}
func requireNonEmptyString(entry map[string]any, key string) bool {
s, ok := entry[key].(string)
return ok && strings.TrimSpace(s) != ""
}
func requireStringArray(entry map[string]any, key string) bool {
raw, ok := entry[key].([]any)
if !ok {
return false
}
for _, item := range raw {
if _, ok := item.(string); !ok {
return false
}
}
return true
}
func stringSliceContains(values []string, target string) bool {
for _, v := range values {
if v == target {
return true
}
}
return false
}
@@ -0,0 +1,358 @@
// 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 cli
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// TestEmbeddedSchemaCatalogStructure gates the delivered catalog: every tool
// entry must conform to the unified closed structure. If this fails after
// regeneration, either fix the generator inputs or deliberately extend the
// whitelist in schema_catalog_structure.go.
func TestEmbeddedSchemaCatalogStructure(t *testing.T) {
// After PR #656 the catalog is embedded as per-product shards, not a single
// JSON file. The loaded snapshot is the reassembled result — serialize it
// back to JSON and validate the same closed structure.
loaded := embeddedSchemaCatalog()
data, err := json.Marshal(loaded.Snapshot)
if err != nil {
t.Fatalf("marshal embedded catalog snapshot: %v", err)
}
if err := ValidateCatalogStructure(data); err != nil {
t.Fatalf("embedded schema catalog violates the unified command structure: %v", err)
}
}
func validCatalogToolEntry() map[string]any {
return map[string]any{
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": []any{"skills/mono/references/products/aitable.md"},
"agent_summary": "创建多维表格记录",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": []any{"批量更新时请用 record update"},
"canonical_path": "aitable.record_create",
"cli_name": "create",
"cli_path": "aitable record create",
"confirmation": "not_required",
"description": "在数据表中创建一条或多条记录。",
"display": "多维表格",
"effect": "write",
"effect_source": "command-verb",
"examples": []any{"dws aitable record create --base-id x"},
"field_provenance": map[string]any{"effect": map[string]any{"precedence": "reviewed_explicit"}},
"has_parameters": true,
"idempotency": "non_idempotent",
"interface_mode": "mcp",
"interface_ref": map[string]any{"product_id": "aitable", "rpc_name": "create_records"},
"is_alias": false,
"name": "record create",
"parameter_count": float64(1),
"parameters": map[string]any{
"base-id": map[string]any{
"type": "string",
"description": "目标多维表格 ID。",
"required": true,
"field_provenance": map[string]any{"description": map[string]any{"precedence": "reviewed_explicit"}},
},
},
"path": "aitable.record_create",
"primary_cli_path": "aitable record create",
"product_id": "aitable",
"reviewed": true,
"risk": "medium",
"source": "cobra+registry",
"title": "创建记录",
"use_when": []any{"需要向多维表格写入新记录时"},
}
}
func catalogPayload(t *testing.T, entry map[string]any) []byte {
t.Helper()
payload, err := json.Marshal(map[string]any{
"version": SchemaCatalogSnapshotVersion,
"tools": map[string]any{"aitable.record_create": entry},
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return payload
}
func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
if err := ValidateCatalogStructure(catalogPayload(t, validCatalogToolEntry())); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
}
func TestValidateCatalogStructureRejectsViolations(t *testing.T) {
cases := []struct {
name string
mutate func(entry map[string]any)
want string
}{
{
name: "unknown tool field",
mutate: func(e map[string]any) { e["surprise"] = "x" },
want: `unknown field "surprise"`,
},
{
name: "missing required core field",
mutate: func(e map[string]any) { delete(e, "agent_summary") },
want: `"agent_summary" must be a non-empty string`,
},
{
name: "bad effect enum",
mutate: func(e map[string]any) { e["effect"] = "mutate" },
want: `"effect" = "mutate"`,
},
{
// 非字符串枚举字段:类型错误由字符串校验报告,枚举循环跳过。
name: "non-string enum field skips enum check",
mutate: func(e map[string]any) { e["effect"] = float64(1) },
want: `"effect" must be a non-empty string`,
},
{
name: "mcp without interface_ref",
mutate: func(e map[string]any) { delete(e, "interface_ref") },
want: "interface_mode=mcp requires interface_ref",
},
{
name: "composite without interface_reason",
mutate: func(e map[string]any) {
e["interface_mode"] = "composite"
delete(e, "interface_ref")
},
want: "interface_mode=composite requires a non-empty interface_reason",
},
{
name: "composite must not keep interface_ref",
mutate: func(e map[string]any) {
e["interface_mode"] = "composite"
e["interface_reason"] = "本地组合多个 MCP 调用"
},
want: "interface_mode=composite must not set interface_ref",
},
{
name: "parameter_count mismatch",
mutate: func(e map[string]any) { e["parameter_count"] = float64(2) },
want: "parameter_count = 2, want 1",
},
{
name: "has_parameters mismatch",
mutate: func(e map[string]any) { e["has_parameters"] = false },
want: "has_parameters = false, want true",
},
{
name: "param missing required",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
delete(params["base-id"].(map[string]any), "required")
},
want: `parameter "base-id": required must be a boolean`,
},
{
name: "param unknown field",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["mystery"] = true
},
want: `parameter "base-id": unknown field "mystery"`,
},
{
name: "param bad type enum",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["type"] = "text"
},
want: `parameter "base-id": type = "text"`,
},
{
name: "param default must be string",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["default"] = float64(3)
},
want: `parameter "base-id": default must be a string`,
},
{
name: "reviewed must be bool",
mutate: func(e map[string]any) { e["reviewed"] = "yes" },
want: `"reviewed" must be a boolean`,
},
{
name: "examples must be string array",
mutate: func(e map[string]any) { e["examples"] = []any{"ok", float64(1)} },
want: `"examples" must be an array of strings`,
},
{
name: "use_when must be an array at all",
mutate: func(e map[string]any) { e["use_when"] = "单个字符串" },
want: `"use_when" must be an array of strings`,
},
{
name: "field_provenance must be object",
mutate: func(e map[string]any) { e["field_provenance"] = "not-an-object" },
want: `"field_provenance" must be an object`,
},
{
name: "parameters must be object",
mutate: func(e map[string]any) { e["parameters"] = []any{"x"} },
want: `"parameters" must be an object`,
},
{
name: "parameter_count must be number",
mutate: func(e map[string]any) { e["parameter_count"] = "1" },
want: `"parameter_count" must be a number`,
},
{
name: "parameter entry must be object",
mutate: func(e map[string]any) {
e["parameters"] = map[string]any{"base-id": "not-an-object"}
e["parameter_count"] = float64(1)
},
want: `parameter "base-id" must be an object`,
},
{
name: "interface_ref must be object",
mutate: func(e map[string]any) { e["interface_ref"] = "aitable.create_records" },
want: "interface_ref must be an object",
},
{
name: "interface_ref keys must be non-empty",
mutate: func(e map[string]any) {
e["interface_ref"] = map[string]any{"product_id": " ", "rpc_name": float64(1)}
},
want: "interface_ref.product_id must be a non-empty string",
},
{
name: "mcp must not set interface_reason",
mutate: func(e map[string]any) { e["interface_reason"] = "多余理由" },
want: "interface_mode=mcp must not set interface_reason",
},
{
name: "param description must be non-empty",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["description"] = " "
},
want: `parameter "base-id": description must be a non-empty string`,
},
{
name: "param field_provenance must be object",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["field_provenance"] = "x"
},
want: `parameter "base-id": field_provenance must be an object`,
},
{
name: "param cli_required must be bool",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["cli_required"] = "yes"
},
want: `parameter "base-id": cli_required must be a boolean`,
},
{
name: "param enum must be array",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = "A,B"
},
want: `parameter "base-id": enum must be an array`,
},
{
name: "param enum items must be strings",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = []any{"A", float64(2)}
},
want: `parameter "base-id": enum[1] must be a string`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entry := validCatalogToolEntry()
tc.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil {
t.Fatalf("ValidateCatalogStructure() = nil, want violation containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestValidateCatalogStructureRejectsBadEnvelope(t *testing.T) {
if err := ValidateCatalogStructure([]byte(`{"version":99,"tools":{}}`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want version error")
}
if err := ValidateCatalogStructure([]byte(`{"version":1,"tools":{}}`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want empty-tools error")
}
if err := ValidateCatalogStructure([]byte(`not json`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want decode error")
}
}
func TestValidateCatalogStructureSortsViolationsAcrossTools(t *testing.T) {
// 两个工具各制造两条违规,驱动排序比较器的 tool 与 message 两个分支。
bad1 := validCatalogToolEntry()
bad1["effect"] = "mutate"
bad1["risk"] = "extreme"
bad2 := validCatalogToolEntry()
bad2["canonical_path"] = "zz.last_tool"
bad2["effect"] = "mutate"
bad2["risk"] = "extreme"
payload, err := json.Marshal(map[string]any{
"version": SchemaCatalogSnapshotVersion,
"tools": map[string]any{"zz.last_tool": bad2, "aitable.record_create": bad1},
})
if err != nil {
t.Fatal(err)
}
verr := ValidateCatalogStructure(payload)
if verr == nil {
t.Fatal("ValidateCatalogStructure() = nil, want sorted violations")
}
msg := verr.Error()
if !strings.Contains(msg, "(4 total)") {
t.Fatalf("error = %v, want 4 total violations", verr)
}
if first, second := strings.Index(msg, "aitable.record_create"), strings.Index(msg, "zz.last_tool"); first < 0 || second < 0 || first > second {
t.Fatalf("violations not sorted by tool: %v", verr)
}
}
func TestValidateCatalogStructureTruncatesViolationList(t *testing.T) {
// 单工具制造超过 25 条违规,验证截断提示。
entry := validCatalogToolEntry()
for i := 0; i < 30; i++ {
entry[fmt.Sprintf("unknown_field_%02d", i)] = true
}
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want truncated violation list")
}
if !strings.Contains(err.Error(), "more violations") {
t.Fatalf("error = %v, want truncation marker", err)
}
}
+16 -4
View File
@@ -849,7 +849,13 @@ func TestAssemblerUsesReviewedCommandVisibility(t *testing.T) {
}
func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
registry, err := ValidateCommandRegistrySource(embeddedSchemaCommandRegistryJSON)
// After splitting registry into per-product shards, tests use the merged
// JSON (reassembled from shards) for byte-level operations.
merged, err := EmbeddedCommandRegistryMergedJSON()
if err != nil {
t.Fatalf("EmbeddedCommandRegistryMergedJSON() error = %v", err)
}
registry, err := ValidateCommandRegistrySource(merged)
if err != nil {
t.Fatalf("ValidateCommandRegistrySource(embedded) error = %v", err)
}
@@ -861,8 +867,9 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
t.Fatalf("embedded hash = %q, decoded hash = %q", hash, registry.SourceHash())
}
drifted := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"cli_path": "aisearch person"`, `"cli_path": "aisearch people"`, 1)
if drifted == string(embeddedSchemaCommandRegistryJSON) {
// Match both pretty-printed ("cli_path": "...") and compact ("cli_path":"...") JSON.
drifted := strings.Replace(string(merged), `"aisearch person"`, `"aisearch people"`, 1)
if drifted == string(merged) {
t.Fatal("test fixture did not mutate embedded registry")
}
_, err = ValidateCommandRegistrySource([]byte(drifted))
@@ -870,7 +877,12 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
t.Fatalf("drift error = %v", err)
}
unknownField := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
// Test unknown-field rejection: inject an unrecognized top-level key.
unknownField := strings.Replace(string(merged), `"version":1`, `"version":1,"unreviewed":true`, 1)
if unknownField == string(merged) {
// Try with space (pretty-printed format).
unknownField = strings.Replace(string(merged), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
}
_, err = ValidateCommandRegistrySource([]byte(unknownField))
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unknown-field error = %v", err)
+62 -217
View File
@@ -48,223 +48,6 @@
"version"
]
},
{
"id": "shortcut-catalog-surface",
"reason": "Shortcut commands are high-level task entries discovered through dws shortcut list and product skills; they are intentionally separate from the runtime Schema catalog of base Agent tools.",
"reviewed": true,
"commands": [
"aitable +base-get",
"aitable +base-list",
"aitable +base-search",
"aitable +chart-get",
"aitable +chart-widgets-example",
"aitable +dashboard-config-example",
"aitable +dashboard-get",
"aitable +field-get",
"aitable +find-record",
"aitable +form-field-list",
"aitable +form-list",
"aitable +form-share-get",
"aitable +list-tables",
"aitable +record-history-list",
"aitable +record-query",
"aitable +record-query-empty",
"aitable +record-share-links",
"aitable +record-share-url",
"aitable +resolve-base",
"aitable +resolve-table",
"aitable +role-list",
"aitable +section-list-empty",
"aitable +section-list-nodes",
"aitable +table-get",
"aitable +template-search",
"aitable +view-get",
"aitable +view-get-frozen-cols",
"aitable +view-get-lock",
"aitable +view-get-row-height",
"attendance +check-record",
"attendance +check-result",
"attendance +get-adjustment-rule",
"attendance +get-approve-template",
"attendance +get-checkin-record",
"attendance +get-leave-records",
"attendance +get-overtime-rule",
"attendance +get-schedule",
"attendance +get-self-setting",
"attendance +get-summary",
"attendance +list-approve",
"attendance +list-leave-types",
"attendance +my-attendance",
"attendance +query-report-data",
"attendance +search-adjustment-rule",
"attendance +search-class",
"attendance +search-group",
"attendance +search-overtime-rule",
"attendance +this-month",
"calendar +agenda",
"calendar +attendee-list",
"calendar +book",
"calendar +book-list",
"calendar +book-search",
"calendar +cancel-event",
"calendar +conflicts",
"calendar +free",
"calendar +free-slots",
"calendar +freebusy",
"calendar +invite",
"calendar +my-free",
"calendar +next-event",
"calendar +reschedule",
"calendar +room-groups",
"calendar +room-search",
"calendar +suggest-time",
"calendar +today",
"calendar +tomorrow",
"calendar +week",
"chat +at-me",
"chat +bot-find",
"chat +bot-search",
"chat +broadcast",
"chat +category-create",
"chat +category-delete",
"chat +category-list",
"chat +category-rename",
"chat +chat-bots",
"chat +chat-dismiss",
"chat +chat-invite-url",
"chat +chat-list-all",
"chat +chat-list-join-requests",
"chat +chat-list-mine",
"chat +chat-mute",
"chat +chat-role-add",
"chat +chat-role-list",
"chat +chat-role-query-user",
"chat +chat-role-set-user",
"chat +chat-role-update",
"chat +chat-search",
"chat +chat-set-admin",
"chat +chat-set-history",
"chat +chat-update-alias",
"chat +chat-update-nick",
"chat +conversation-clear-all-red-point",
"chat +conversation-info",
"chat +conversation-list",
"chat +conversation-list-top",
"chat +dm",
"chat +group-members",
"chat +messages-list-direct",
"chat +messages-list-pin",
"chat +messages-list-unread-conversations",
"chat +messages-mget",
"chat +messages-query-send-status",
"chat +messages-read-status",
"chat +messages-send-by-webhook",
"chat +messages-update-card",
"chat +my-groups",
"chat +send-to-group",
"chat +unread-chats",
"contact +by-mobile",
"contact +dept-members",
"contact +list-dept-members",
"contact +list-followings",
"contact +list-role-members",
"contact +list-roles",
"contact +list-sub-depts",
"contact +lookup",
"contact +me",
"contact +org",
"contact +resolve-dept",
"contact +search-mobile",
"contact +search-user",
"contact +team",
"devapp +create",
"devapp +delete",
"devapp +disable",
"devapp +enable",
"devapp +event-list",
"devapp +get",
"devapp +list",
"devapp +member-add",
"devapp +member-list",
"devapp +member-remove",
"devapp +permission-list",
"devapp +robot-get",
"devapp +update",
"devapp +version-check-approval",
"devapp +version-get",
"devapp +version-list",
"devapp +version-status",
"devapp +webapp-config",
"devapp +webapp-get",
"ding +list",
"ding +recall-personal",
"ding +receiver-status",
"ding +send-personal",
"doc +comment-create",
"doc +comment-list",
"doc +comment-reply",
"doc +copy",
"doc +doc-append",
"doc +export-get",
"doc +export-submit",
"doc +find-doc",
"doc +list",
"doc +move",
"doc +search",
"doc +share-doc",
"doc +template-list",
"doc +template-search",
"doc +version-list",
"doc +version-revert",
"doc +version-save",
"drive +copy",
"drive +find-file",
"drive +info",
"drive +move",
"drive +recent",
"drive +search",
"drive +search-docs",
"mail +contact-list",
"mail +find-mail-user",
"mail +folder-list",
"mail +recent-mail",
"mail +search-mail",
"mail +tag-list",
"mail +template-list",
"mail +thread-list",
"mail +unread-mail",
"mail +user-search",
"minutes +detail",
"minutes +list-all",
"minutes +list-mine",
"minutes +list-shared",
"minutes +record-start",
"minutes +replace-batch",
"oa +list-cc",
"oa +list-executed",
"oa +list-forms",
"oa +list-pending",
"oa +list-submitted",
"oa +my-initiated",
"oa +search-forms",
"report +inbox-list",
"report +outbox-list",
"sheet +list-sheets",
"sheet +read",
"todo +assign",
"todo +assign-multi",
"todo +created-todos",
"todo +get",
"todo +get-my-tasks",
"todo +list-attachment",
"todo +list-comment",
"todo +list-sub",
"todo +overdue",
"todo +remind",
"todo +todo-done",
"wiki +space-search"
]
},
{
"id": "agoal-out-of-surface",
"reason": "The Agoal product remains executable for compatibility but is outside the currently reviewed open-source Agent command surface.",
@@ -370,6 +153,68 @@
"todo task list-sub",
"todo task remove-attachment"
]
},
{
"id": "chat-shortcuts-pending-schema-curation",
"reason": "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
"reviewed": true,
"commands": [
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
]
}
]
}
+108 -5
View File
@@ -6,11 +6,12 @@ package cli
import (
"bytes"
"crypto/sha256"
_ "embed"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"regexp"
"sort"
"strings"
@@ -25,8 +26,11 @@ const commandRegistrySchemaRef = "./schema_command_registry.schema.json"
// sole source of stable command identity and navigation. Catalog and generated
// metadata are downstream views and must never be read back here.
//go:embed schema_command_registry.json
var embeddedSchemaCommandRegistryJSON []byte
//go:embed schema_command_registry/registry.json
var embeddedSchemaCommandRegistryEnvelopeJSON []byte
//go:embed schema_command_registry/products
var embeddedSchemaCommandRegistryProducts embed.FS
//go:embed schema_command_registry.schema.json
var embeddedSchemaCommandRegistrySchemaJSON []byte
@@ -34,7 +38,7 @@ var embeddedSchemaCommandRegistrySchemaJSON []byte
var (
commandRegistryProductIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
commandRegistryCanonicalPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9_.-]*$`)
commandRegistryCLIPathToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
commandRegistryCLIPathToken = regexp.MustCompile(`^(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\+[A-Za-z0-9][A-Za-z0-9._:-]*)$`)
)
type schemaCommandRegistrySnapshot struct {
@@ -96,11 +100,110 @@ var (
func loadEmbeddedCommandRegistry() (CommandRegistry, error) {
embeddedSchemaCommandRegistryOnce.Do(func() {
embeddedSchemaCommandRegistryData, embeddedSchemaCommandRegistryErr = decodeCommandRegistry(embeddedSchemaCommandRegistryJSON)
embeddedSchemaCommandRegistryData, embeddedSchemaCommandRegistryErr = assembleCommandRegistry()
})
return cloneCommandRegistry(embeddedSchemaCommandRegistryData), embeddedSchemaCommandRegistryErr
}
// assembleCommandRegistry reads the per-product shards embedded at build time
// and reassembles them into a single CommandRegistry, identical to the previous
// single-file layout. Mirrors assembleEmbeddedSchemaCatalog for catalog shards.
func assembleCommandRegistry() (CommandRegistry, error) {
return assembleCommandRegistryFrom(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
}
// assembleCommandRegistryFrom reassembles an envelope plus product shard
// directory. Split from assembleCommandRegistry so shard failure modes stay
// testable against fake filesystems.
func assembleCommandRegistryFrom(envelopeJSON []byte, shards fs.FS, dir string) (CommandRegistry, error) {
var envelope struct {
Schema string `json:"$schema,omitempty"`
Version int `json:"version"`
}
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
return CommandRegistry{}, fmt.Errorf("decode embedded command registry envelope: %w", err)
}
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return CommandRegistry{}, fmt.Errorf("read embedded command registry products: %w", err)
}
var snapshot schemaCommandRegistrySnapshot
snapshot.Schema = envelope.Schema
snapshot.Version = envelope.Version
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
if readErr != nil {
return CommandRegistry{}, fmt.Errorf("read embedded command registry shard %s: %w", entry.Name(), readErr)
}
var product schemaCommandRegistryProduct
if err := json.Unmarshal(data, &product); err != nil {
return CommandRegistry{}, fmt.Errorf("decode embedded command registry shard %s: %w", entry.Name(), err)
}
snapshot.Products = append(snapshot.Products, product)
}
// Encode to bytes and decode via the existing pipeline so all validation
// (canonical patterns, duplicate detection, visibility) runs identically.
// Marshal of this plain struct cannot fail.
data, _ := json.Marshal(snapshot)
return decodeCommandRegistry(data)
}
// EmbeddedCommandRegistryMergedJSON returns the per-product shards reassembled
// into a single JSON document matching the pre-split layout. Used by tests that
// need the full registry bytes.
func EmbeddedCommandRegistryMergedJSON() ([]byte, error) {
return mergedCommandRegistryJSON(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
}
// mergedCommandRegistryJSON is the injectable core of
// EmbeddedCommandRegistryMergedJSON.
func mergedCommandRegistryJSON(envelopeJSON []byte, shards fs.FS, dir string) ([]byte, error) {
var envelope struct {
Schema string `json:"$schema,omitempty"`
Version int `json:"version"`
}
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
return nil, err
}
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return nil, err
}
products := make([]json.RawMessage, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := fs.ReadFile(shards, dir+"/"+entry.Name())
if err != nil {
return nil, err
}
products = append(products, json.RawMessage(data))
}
result := struct {
Schema string `json:"$schema,omitempty"`
Version int `json:"version"`
Products json.RawMessage `json:"products"`
}{
Schema: envelope.Schema,
Version: envelope.Version,
}
if result.Schema == "" {
result.Schema = "./schema_command_registry.schema.json"
}
result.Products, err = json.Marshal(products)
if err != nil {
return nil, err
}
return json.Marshal(result)
}
// ValidateCommandRegistrySource validates a compatibility --surface input and
// requires it to be semantically identical to the embedded reviewed registry.
// Generators may retain the flag for migration, but cannot use it to introduce
File diff suppressed because it is too large Load Diff
@@ -36,8 +36,8 @@
},
"cliPath": {
"type": "string",
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])[A-Za-z0-9][A-Za-z0-9._:-]*(?: [A-Za-z0-9][A-Za-z0-9._:-]*)*$",
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace."
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*)(?: (?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*))*$",
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace. A token may use the reviewed '+' shortcut prefix."
},
"product": {
"type": "object",
@@ -0,0 +1,17 @@
{
"id": "aisearch",
"tools": [
{
"canonical_path": "aisearch.search_enterprise_behavior",
"cli_path": "aisearch behavior"
},
{
"canonical_path": "aisearch.search_enterprise",
"cli_path": "aisearch enterprise"
},
{
"canonical_path": "aisearch.enterprise_person_search",
"cli_path": "aisearch person"
}
]
}
@@ -0,0 +1,588 @@
{
"id": "aitable",
"tools": [
{
"canonical_path": "aitable.advperm_disable",
"cli_path": "aitable advperm disable"
},
{
"canonical_path": "aitable.advperm_enable",
"cli_path": "aitable advperm enable"
},
{
"canonical_path": "aitable.advperm_role_create",
"cli_path": "aitable advperm role-create"
},
{
"canonical_path": "aitable.advperm_role_delete",
"cli_path": "aitable advperm role-delete"
},
{
"canonical_path": "aitable.advperm_role_get",
"cli_path": "aitable advperm role-get"
},
{
"canonical_path": "aitable.advperm_role_list",
"cli_path": "aitable advperm role-list"
},
{
"canonical_path": "aitable.advperm_role_update",
"cli_path": "aitable advperm role-update"
},
{
"canonical_path": "aitable.attachment_upload",
"cli_path": "aitable attachment upload"
},
{
"canonical_path": "aitable.base_copy",
"cli_path": "aitable base copy"
},
{
"canonical_path": "aitable.base_create",
"cli_path": "aitable base create"
},
{
"canonical_path": "aitable.base_delete",
"cli_path": "aitable base delete"
},
{
"canonical_path": "aitable.base_get",
"cli_path": "aitable base get"
},
{
"canonical_path": "aitable.base_get_primary_doc_id",
"cli_path": "aitable base get-primary-doc-id"
},
{
"canonical_path": "aitable.base_list",
"cli_path": "aitable base list"
},
{
"canonical_path": "aitable.base_search",
"cli_path": "aitable base search"
},
{
"canonical_path": "aitable.base_update",
"cli_path": "aitable base update"
},
{
"canonical_path": "aitable.chart_create",
"cli_path": "aitable chart create"
},
{
"canonical_path": "aitable.chart_delete",
"cli_path": "aitable chart delete"
},
{
"canonical_path": "aitable.chart_get",
"cli_path": "aitable chart get"
},
{
"canonical_path": "aitable.chart_share_get",
"cli_path": "aitable chart share get"
},
{
"canonical_path": "aitable.chart_share_update",
"cli_path": "aitable chart share update"
},
{
"canonical_path": "aitable.chart_update",
"cli_path": "aitable chart update"
},
{
"canonical_path": "aitable.chart_widgets_example",
"cli_path": "aitable chart widgets-example"
},
{
"canonical_path": "aitable.create",
"cli_path": "aitable create"
},
{
"canonical_path": "aitable.dashboard_arrange",
"cli_path": "aitable dashboard arrange"
},
{
"canonical_path": "aitable.dashboard_config_example",
"cli_path": "aitable dashboard config-example"
},
{
"canonical_path": "aitable.dashboard_create",
"cli_path": "aitable dashboard create"
},
{
"canonical_path": "aitable.dashboard_delete",
"cli_path": "aitable dashboard delete"
},
{
"canonical_path": "aitable.dashboard_get",
"cli_path": "aitable dashboard get"
},
{
"canonical_path": "aitable.dashboard_share_get",
"cli_path": "aitable dashboard share get"
},
{
"canonical_path": "aitable.dashboard_share_update",
"cli_path": "aitable dashboard share update"
},
{
"canonical_path": "aitable.dashboard_update",
"cli_path": "aitable dashboard update"
},
{
"canonical_path": "aitable.export_data",
"cli_path": "aitable export data"
},
{
"canonical_path": "aitable.field_create",
"cli_path": "aitable field create"
},
{
"canonical_path": "aitable.field_delete",
"cli_path": "aitable field delete"
},
{
"canonical_path": "aitable.field_get",
"cli_path": "aitable field get"
},
{
"canonical_path": "aitable.field_list",
"cli_path": "aitable field list"
},
{
"canonical_path": "aitable.field_search_options",
"cli_path": "aitable field search-options"
},
{
"canonical_path": "aitable.field_update",
"cli_path": "aitable field update"
},
{
"canonical_path": "aitable.form_create",
"cli_path": "aitable form create"
},
{
"canonical_path": "aitable.form_delete",
"cli_path": "aitable form delete"
},
{
"canonical_path": "aitable.form_field_hide",
"cli_path": "aitable form field hide"
},
{
"canonical_path": "aitable.form_field_list",
"cli_path": "aitable form field list"
},
{
"canonical_path": "aitable.form_field_update",
"cli_path": "aitable form field update"
},
{
"canonical_path": "aitable.form_get",
"cli_path": "aitable form get"
},
{
"canonical_path": "aitable.form_list",
"cli_path": "aitable form list"
},
{
"canonical_path": "aitable.form_questions_create",
"cli_path": "aitable form questions create"
},
{
"canonical_path": "aitable.form_questions_delete",
"cli_path": "aitable form questions delete"
},
{
"canonical_path": "aitable.form_share_get",
"cli_path": "aitable form share get"
},
{
"canonical_path": "aitable.form_share_update",
"cli_path": "aitable form share update"
},
{
"canonical_path": "aitable.form_update",
"cli_path": "aitable form update"
},
{
"canonical_path": "aitable.import_data",
"cli_path": "aitable import data"
},
{
"canonical_path": "aitable.import_upload",
"cli_path": "aitable import upload"
},
{
"canonical_path": "aitable.info",
"cli_path": "aitable info"
},
{
"canonical_path": "aitable.list",
"cli_path": "aitable list"
},
{
"canonical_path": "aitable.record_batch_update",
"cli_path": "aitable record batch-update"
},
{
"canonical_path": "aitable.record_create",
"cli_path": "aitable record create"
},
{
"canonical_path": "aitable.record_delete",
"cli_path": "aitable record delete"
},
{
"canonical_path": "aitable.record_get",
"cli_path": "aitable record get"
},
{
"canonical_path": "aitable.record_history_list",
"cli_path": "aitable record history-list"
},
{
"canonical_path": "aitable.record_primary_doc_create",
"cli_path": "aitable record primary-doc-create"
},
{
"canonical_path": "aitable.record_primary_doc_get",
"cli_path": "aitable record primary-doc-get"
},
{
"canonical_path": "aitable.query_records",
"cli_path": "aitable record query",
"aliases": [
"aitable record list"
]
},
{
"canonical_path": "aitable.record_query_empty",
"cli_path": "aitable record query-empty"
},
{
"canonical_path": "aitable.record_share_url",
"cli_path": "aitable record share-url"
},
{
"canonical_path": "aitable.record_update",
"cli_path": "aitable record update"
},
{
"canonical_path": "aitable.record_upsert",
"cli_path": "aitable record upsert"
},
{
"canonical_path": "aitable.search",
"cli_path": "aitable search"
},
{
"canonical_path": "aitable.section_create",
"cli_path": "aitable section create"
},
{
"canonical_path": "aitable.section_delete",
"cli_path": "aitable section delete"
},
{
"canonical_path": "aitable.section_list_empty",
"cli_path": "aitable section list-empty"
},
{
"canonical_path": "aitable.section_list_nodes",
"cli_path": "aitable section list-nodes"
},
{
"canonical_path": "aitable.section_move_node",
"cli_path": "aitable section move-node"
},
{
"canonical_path": "aitable.section_rename",
"cli_path": "aitable section rename"
},
{
"canonical_path": "aitable.section_reorder",
"cli_path": "aitable section reorder"
},
{
"canonical_path": "aitable.table_create",
"cli_path": "aitable table create"
},
{
"canonical_path": "aitable.table_delete",
"cli_path": "aitable table delete"
},
{
"canonical_path": "aitable.table_get",
"cli_path": "aitable table get"
},
{
"canonical_path": "aitable.table_list",
"cli_path": "aitable table list"
},
{
"canonical_path": "aitable.table_update",
"cli_path": "aitable table update"
},
{
"canonical_path": "aitable.template_search",
"cli_path": "aitable template search"
},
{
"canonical_path": "aitable.view_create",
"cli_path": "aitable view create"
},
{
"canonical_path": "aitable.view_delete",
"cli_path": "aitable view delete"
},
{
"canonical_path": "aitable.view_duplicate",
"cli_path": "aitable view duplicate"
},
{
"canonical_path": "aitable.view_get_aggregate",
"cli_path": "aitable view get aggregate"
},
{
"canonical_path": "aitable.view_get_card",
"cli_path": "aitable view get card"
},
{
"canonical_path": "aitable.view_get_field_widths",
"cli_path": "aitable view get field-widths"
},
{
"canonical_path": "aitable.view_get_fill_color_rule",
"cli_path": "aitable view get fill-color-rule"
},
{
"canonical_path": "aitable.view_get_filter",
"cli_path": "aitable view get filter"
},
{
"canonical_path": "aitable.view_get_frozen_cols",
"cli_path": "aitable view get frozen-cols"
},
{
"canonical_path": "aitable.view_get_group",
"cli_path": "aitable view get group"
},
{
"canonical_path": "aitable.view_get_lock",
"cli_path": "aitable view get lock"
},
{
"canonical_path": "aitable.view_get_row_height",
"cli_path": "aitable view get row-height"
},
{
"canonical_path": "aitable.view_get_sort",
"cli_path": "aitable view get sort"
},
{
"canonical_path": "aitable.view_get_timebar",
"cli_path": "aitable view get timebar"
},
{
"canonical_path": "aitable.view_get_visible_fields",
"cli_path": "aitable view get visible-fields"
},
{
"canonical_path": "aitable.view_list",
"cli_path": "aitable view list"
},
{
"canonical_path": "aitable.view_lock",
"cli_path": "aitable view lock"
},
{
"canonical_path": "aitable.view_update_aggregate",
"cli_path": "aitable view update aggregate"
},
{
"canonical_path": "aitable.view_update_card",
"cli_path": "aitable view update card"
},
{
"canonical_path": "aitable.view_update_field_widths",
"cli_path": "aitable view update field-widths"
},
{
"canonical_path": "aitable.view_update_fill_color_rule",
"cli_path": "aitable view update fill-color-rule"
},
{
"canonical_path": "aitable.view_update_filter",
"cli_path": "aitable view update filter"
},
{
"canonical_path": "aitable.view_update_frozen_cols",
"cli_path": "aitable view update frozen-cols"
},
{
"canonical_path": "aitable.view_update_group",
"cli_path": "aitable view update group"
},
{
"canonical_path": "aitable.view_update_name",
"cli_path": "aitable view update name"
},
{
"canonical_path": "aitable.view_update_row_height",
"cli_path": "aitable view update row-height"
},
{
"canonical_path": "aitable.view_update_sort",
"cli_path": "aitable view update sort"
},
{
"canonical_path": "aitable.view_update_timebar",
"cli_path": "aitable view update timebar"
},
{
"canonical_path": "aitable.view_update_visible_fields",
"cli_path": "aitable view update visible-fields"
},
{
"canonical_path": "aitable.workflow_create",
"cli_path": "aitable workflow create"
},
{
"canonical_path": "aitable.workflow_disable",
"cli_path": "aitable workflow disable"
},
{
"canonical_path": "aitable.workflow_enable",
"cli_path": "aitable workflow enable"
},
{
"canonical_path": "aitable.workflow_get",
"cli_path": "aitable workflow get"
},
{
"canonical_path": "aitable.workflow_list",
"cli_path": "aitable workflow list"
},
{
"canonical_path": "aitable.workflow_update",
"cli_path": "aitable workflow update"
},
{
"canonical_path": "aitable.shortcut_base_list",
"cli_path": "aitable +base-list"
},
{
"canonical_path": "aitable.shortcut_base_search",
"cli_path": "aitable +base-search"
},
{
"canonical_path": "aitable.shortcut_base_get",
"cli_path": "aitable +base-get"
},
{
"canonical_path": "aitable.shortcut_table_get",
"cli_path": "aitable +table-get"
},
{
"canonical_path": "aitable.shortcut_field_get",
"cli_path": "aitable +field-get"
},
{
"canonical_path": "aitable.shortcut_record_query",
"cli_path": "aitable +record-query"
},
{
"canonical_path": "aitable.shortcut_record_query_empty",
"cli_path": "aitable +record-query-empty"
},
{
"canonical_path": "aitable.shortcut_record_history_list",
"cli_path": "aitable +record-history-list"
},
{
"canonical_path": "aitable.shortcut_record_share_url",
"cli_path": "aitable +record-share-url"
},
{
"canonical_path": "aitable.shortcut_template_search",
"cli_path": "aitable +template-search"
},
{
"canonical_path": "aitable.shortcut_view_get",
"cli_path": "aitable +view-get"
},
{
"canonical_path": "aitable.shortcut_view_get_lock",
"cli_path": "aitable +view-get-lock"
},
{
"canonical_path": "aitable.shortcut_view_get_frozen_cols",
"cli_path": "aitable +view-get-frozen-cols"
},
{
"canonical_path": "aitable.shortcut_view_get_row_height",
"cli_path": "aitable +view-get-row-height"
},
{
"canonical_path": "aitable.shortcut_form_list",
"cli_path": "aitable +form-list"
},
{
"canonical_path": "aitable.shortcut_form_field_list",
"cli_path": "aitable +form-field-list"
},
{
"canonical_path": "aitable.shortcut_form_share_get",
"cli_path": "aitable +form-share-get"
},
{
"canonical_path": "aitable.shortcut_dashboard_config_example",
"cli_path": "aitable +dashboard-config-example"
},
{
"canonical_path": "aitable.shortcut_dashboard_get",
"cli_path": "aitable +dashboard-get"
},
{
"canonical_path": "aitable.shortcut_chart_widgets_example",
"cli_path": "aitable +chart-widgets-example"
},
{
"canonical_path": "aitable.shortcut_chart_get",
"cli_path": "aitable +chart-get"
},
{
"canonical_path": "aitable.shortcut_role_list",
"cli_path": "aitable +role-list"
},
{
"canonical_path": "aitable.shortcut_section_list_empty",
"cli_path": "aitable +section-list-empty"
},
{
"canonical_path": "aitable.shortcut_section_list_nodes",
"cli_path": "aitable +section-list-nodes"
},
{
"canonical_path": "aitable.shortcut_find_record",
"cli_path": "aitable +find-record"
},
{
"canonical_path": "aitable.shortcut_list_tables",
"cli_path": "aitable +list-tables"
},
{
"canonical_path": "aitable.shortcut_record_share_links",
"cli_path": "aitable +record-share-links"
},
{
"canonical_path": "aitable.shortcut_resolve_base",
"cli_path": "aitable +resolve-base"
},
{
"canonical_path": "aitable.shortcut_resolve_table",
"cli_path": "aitable +resolve-table"
}
]
}
@@ -0,0 +1,233 @@
{
"id": "attendance",
"tools": [
{
"canonical_path": "attendance.adjustment_get",
"cli_path": "attendance adjustment get"
},
{
"canonical_path": "attendance.adjustment_search",
"cli_path": "attendance adjustment search"
},
{
"canonical_path": "attendance.approve_list",
"cli_path": "attendance approve list"
},
{
"canonical_path": "attendance.approve_templates",
"cli_path": "attendance approve templates"
},
{
"canonical_path": "attendance.boss_check",
"cli_path": "attendance boss-check"
},
{
"canonical_path": "attendance.check_record",
"cli_path": "attendance check record"
},
{
"canonical_path": "attendance.check_result",
"cli_path": "attendance check result"
},
{
"canonical_path": "attendance.checkin_records",
"cli_path": "attendance checkin records"
},
{
"canonical_path": "attendance.class_create",
"cli_path": "attendance class create"
},
{
"canonical_path": "attendance.class_get",
"cli_path": "attendance class get"
},
{
"canonical_path": "attendance.class_search",
"cli_path": "attendance class search"
},
{
"canonical_path": "attendance.class_update",
"cli_path": "attendance class update"
},
{
"canonical_path": "attendance.globalsetting_get",
"cli_path": "attendance globalsetting get"
},
{
"canonical_path": "attendance.globalsetting_save",
"cli_path": "attendance globalsetting save"
},
{
"canonical_path": "attendance.group_create",
"cli_path": "attendance group create"
},
{
"canonical_path": "attendance.group_filtered_get",
"cli_path": "attendance group filtered-get"
},
{
"canonical_path": "attendance.group_get",
"cli_path": "attendance group get"
},
{
"canonical_path": "attendance.group_search",
"cli_path": "attendance group search"
},
{
"canonical_path": "attendance.group_update",
"cli_path": "attendance group update"
},
{
"canonical_path": "attendance.group_update_members",
"cli_path": "attendance group update-members"
},
{
"canonical_path": "attendance.overtime_get",
"cli_path": "attendance overtime get"
},
{
"canonical_path": "attendance.overtime_search",
"cli_path": "attendance overtime search"
},
{
"canonical_path": "attendance.get_user_attendance_record",
"cli_path": "attendance record get"
},
{
"canonical_path": "attendance.report_columns",
"cli_path": "attendance report columns"
},
{
"canonical_path": "attendance.report_query_data",
"cli_path": "attendance report query-data"
},
{
"canonical_path": "attendance.report_query_leave",
"cli_path": "attendance report query-leave"
},
{
"canonical_path": "attendance.query_attendance_group_or_rules",
"cli_path": "attendance rules"
},
{
"canonical_path": "attendance.schedule_get",
"cli_path": "attendance schedule get"
},
{
"canonical_path": "attendance.schedule_import",
"cli_path": "attendance schedule import"
},
{
"canonical_path": "attendance.selfsetting_get",
"cli_path": "attendance selfsetting get"
},
{
"canonical_path": "attendance.selfsetting_save",
"cli_path": "attendance selfsetting save"
},
{
"canonical_path": "attendance.batch_get_employee_shifts",
"cli_path": "attendance shift list"
},
{
"canonical_path": "attendance.get_attendance_summary",
"cli_path": "attendance summary"
},
{
"canonical_path": "attendance.vacation_balance",
"cli_path": "attendance vacation balance"
},
{
"canonical_path": "attendance.vacation_records",
"cli_path": "attendance vacation records"
},
{
"canonical_path": "attendance.vacation_save_balance",
"cli_path": "attendance vacation save-balance"
},
{
"canonical_path": "attendance.vacation_types",
"cli_path": "attendance vacation types"
},
{
"canonical_path": "attendance.vacation_update_type",
"cli_path": "attendance vacation update-type"
},
{
"canonical_path": "attendance.shortcut_check_result",
"cli_path": "attendance +check-result"
},
{
"canonical_path": "attendance.shortcut_check_record",
"cli_path": "attendance +check-record"
},
{
"canonical_path": "attendance.shortcut_list_approve",
"cli_path": "attendance +list-approve"
},
{
"canonical_path": "attendance.shortcut_get_approve_template",
"cli_path": "attendance +get-approve-template"
},
{
"canonical_path": "attendance.shortcut_get_schedule",
"cli_path": "attendance +get-schedule"
},
{
"canonical_path": "attendance.shortcut_search_class",
"cli_path": "attendance +search-class"
},
{
"canonical_path": "attendance.shortcut_get_adjustment_rule",
"cli_path": "attendance +get-adjustment-rule"
},
{
"canonical_path": "attendance.shortcut_search_adjustment_rule",
"cli_path": "attendance +search-adjustment-rule"
},
{
"canonical_path": "attendance.shortcut_get_overtime_rule",
"cli_path": "attendance +get-overtime-rule"
},
{
"canonical_path": "attendance.shortcut_search_overtime_rule",
"cli_path": "attendance +search-overtime-rule"
},
{
"canonical_path": "attendance.shortcut_search_group",
"cli_path": "attendance +search-group"
},
{
"canonical_path": "attendance.shortcut_get_summary",
"cli_path": "attendance +get-summary"
},
{
"canonical_path": "attendance.shortcut_get_self_setting",
"cli_path": "attendance +get-self-setting"
},
{
"canonical_path": "attendance.shortcut_query_report_data",
"cli_path": "attendance +query-report-data"
},
{
"canonical_path": "attendance.shortcut_list_leave_types",
"cli_path": "attendance +list-leave-types"
},
{
"canonical_path": "attendance.shortcut_get_leave_records",
"cli_path": "attendance +get-leave-records"
},
{
"canonical_path": "attendance.shortcut_get_checkin_record",
"cli_path": "attendance +get-checkin-record"
},
{
"canonical_path": "attendance.shortcut_my_attendance",
"cli_path": "attendance +my-attendance"
},
{
"canonical_path": "attendance.shortcut_this_month",
"cli_path": "attendance +this-month"
}
]
}
@@ -0,0 +1,17 @@
{
"id": "audit",
"tools": [
{
"canonical_path": "audit.export",
"cli_path": "audit export"
},
{
"canonical_path": "audit.tail",
"cli_path": "audit tail"
},
{
"canonical_path": "audit.verify",
"cli_path": "audit verify"
}
]
}
@@ -0,0 +1,165 @@
{
"id": "calendar",
"tools": [
{
"canonical_path": "calendar.list_acls",
"cli_path": "calendar acl list"
},
{
"canonical_path": "calendar.add_attachments",
"cli_path": "calendar attachment add"
},
{
"canonical_path": "calendar.add_calendar_participant",
"cli_path": "calendar attendee add"
},
{
"canonical_path": "calendar.remove_calendar_participant",
"cli_path": "calendar attendee delete"
},
{
"canonical_path": "calendar.get_calendar_participants",
"cli_path": "calendar attendee list"
},
{
"canonical_path": "calendar.get_calendar",
"cli_path": "calendar book get"
},
{
"canonical_path": "calendar.list_calendars",
"cli_path": "calendar book list"
},
{
"canonical_path": "calendar.search_calendar",
"cli_path": "calendar book search"
},
{
"canonical_path": "calendar.query_busy_status",
"cli_path": "calendar busy search"
},
{
"canonical_path": "calendar.create_calendar_event",
"cli_path": "calendar event create"
},
{
"canonical_path": "calendar.delete_calendar_event",
"cli_path": "calendar event delete"
},
{
"canonical_path": "calendar.get_calendar_detail",
"cli_path": "calendar event get"
},
{
"canonical_path": "calendar.list_calendar_events",
"cli_path": "calendar event list"
},
{
"canonical_path": "calendar.respond",
"cli_path": "calendar event respond"
},
{
"canonical_path": "calendar.list_suggested_event_times",
"cli_path": "calendar event suggest"
},
{
"canonical_path": "calendar.update_calendar_event",
"cli_path": "calendar event update"
},
{
"canonical_path": "calendar.add_meeting_room",
"cli_path": "calendar room add"
},
{
"canonical_path": "calendar.delete_meeting_room",
"cli_path": "calendar room delete"
},
{
"canonical_path": "calendar.list_meeting_room_groups",
"cli_path": "calendar room list-groups"
},
{
"canonical_path": "calendar.query_available_meeting_room",
"cli_path": "calendar room search"
},
{
"canonical_path": "calendar.shortcut_agenda",
"cli_path": "calendar +agenda"
},
{
"canonical_path": "calendar.shortcut_attendee_list",
"cli_path": "calendar +attendee-list"
},
{
"canonical_path": "calendar.shortcut_room_search",
"cli_path": "calendar +room-search"
},
{
"canonical_path": "calendar.shortcut_room_groups",
"cli_path": "calendar +room-groups"
},
{
"canonical_path": "calendar.shortcut_freebusy",
"cli_path": "calendar +freebusy"
},
{
"canonical_path": "calendar.shortcut_book_list",
"cli_path": "calendar +book-list"
},
{
"canonical_path": "calendar.shortcut_book_search",
"cli_path": "calendar +book-search"
},
{
"canonical_path": "calendar.shortcut_book",
"cli_path": "calendar +book"
},
{
"canonical_path": "calendar.shortcut_cancel_event",
"cli_path": "calendar +cancel-event"
},
{
"canonical_path": "calendar.shortcut_conflicts",
"cli_path": "calendar +conflicts"
},
{
"canonical_path": "calendar.shortcut_free_slots",
"cli_path": "calendar +free-slots"
},
{
"canonical_path": "calendar.shortcut_free",
"cli_path": "calendar +free"
},
{
"canonical_path": "calendar.shortcut_invite",
"cli_path": "calendar +invite"
},
{
"canonical_path": "calendar.shortcut_my_free",
"cli_path": "calendar +my-free"
},
{
"canonical_path": "calendar.shortcut_next_event",
"cli_path": "calendar +next-event"
},
{
"canonical_path": "calendar.shortcut_reschedule",
"cli_path": "calendar +reschedule"
},
{
"canonical_path": "calendar.shortcut_suggest_time",
"cli_path": "calendar +suggest-time"
},
{
"canonical_path": "calendar.shortcut_today",
"cli_path": "calendar +today"
},
{
"canonical_path": "calendar.shortcut_tomorrow",
"cli_path": "calendar +tomorrow"
},
{
"canonical_path": "calendar.shortcut_week",
"cli_path": "calendar +week"
}
]
}
@@ -0,0 +1,501 @@
{
"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"
},
{
"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"
},
{
"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"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"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"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_conv_member_roles",
"cli_path": "chat group set-admin"
},
{
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
},
{
"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"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"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"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
},
{
"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"
},
{
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
}
]
}

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