Compare commits

..
Author SHA1 Message Date
修雨 d5dbefb27e docs: sync outdated references and add audit-log guide
- architecture.md: rewrite to reflect current plugin-based structure
  (removed market/discovery/ir/generator/compat, added helpers/plugin/audit/pat)
- automation.md: update task routing and repository map to current packages
- command-index.md: fix source citation (internal/plugin replaces removed compat pipeline)
- connect-daemon-service.md: update command path from 'dws devapp robot connect'
  to 'dws dev connect' (7 occurrences + launchd plist args)
- audit-log.md: new doc covering event format, env vars, CLI commands,
  hash chain verification, remote forwarding, and redaction levels
2026-07-06 15:38:57 +08:00
修雨 1c1abf3069 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 15:25:24 +08:00
2321 changed files with 80503 additions and 606825 deletions
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
-34
View File
@@ -1,34 +0,0 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
@@ -1,8 +0,0 @@
---
category: Added
---
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
@@ -1,20 +0,0 @@
---
category: Fixed
---
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
the first failing folder with its depth and reason, and emit a recovery command that
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
quoted so a URL query string or a shell metacharacter cannot change how the copied command
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
containing metacharacters are not inlined at all: the command carries a placeholder and the
original value is shown on a separate line marked as data rather than an executable command.
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
Remote-controlled folder names and server error text are stripped of ANSI escapes and
control characters before they reach the plain-text stderr message. The internal `sortTime`
sort key no longer leaks into `drive list --depth` output on any path.
@@ -1,12 +0,0 @@
---
category: Added
---
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
file|folder`, `--start`, and `--end` for client-side filtering by node type
and modification time on both the pan and workspace routes. Filtering runs
a bounded full scan of the target directory (2000-entry cap, reported via
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
@@ -1,12 +0,0 @@
---
category: Fixed
---
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
single-layer pan route now filters the returned page by name pattern; the
flag was previously accepted but silently ignored.
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
ranks the filtered entries (folders included when `--type folder` is set)
instead of unconditionally dropping folders, so the documented combination
returns the most recently modified folders rather than an empty list.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
@@ -1,15 +0,0 @@
---
category: Added
---
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
between a local folder and a Drive folder. Differences come from exact MD5 by
default or from modification time with `--quick`; `status` is read-only, `pull`
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
Only regular files are transferred — online documents and shortcuts are skipped,
neither side deletes extra files, downloads are staged through a temporary file
and committed with an atomic rename, and remote names that would escape
`--local-folder` are reported as failures instead of being written. Every command
prints a structured summary on stdout and exits non-zero when any item fails.
@@ -1,5 +0,0 @@
---
category: Added
---
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
@@ -1,7 +0,0 @@
---
category: Changed
---
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
@@ -1,6 +0,0 @@
---
category: Fixed
---
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
@@ -1,8 +0,0 @@
---
category: Changed
---
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
@@ -1,9 +0,0 @@
---
category: Added
---
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
@@ -1,11 +0,0 @@
---
category: Fixed
---
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
record queries on the service's 20-record page boundary so multi-page reads
and mutation readbacks no longer report false retryable failures, preserves
`totalCount` when supplied, validates `--dry-run` plans before transport,
follows active deletion readback continuations before proving absence, and
rejects Minutes unshare success until the listening note exists and the
service acknowledges the exact task and member targets.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
@@ -1,9 +0,0 @@
---
category: Added
---
- **AI Table server-side statistics** — adds `dws aitable record stats` for
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
record group-stats` for grouped, distinct, and advanced aggregation through
`query_stats`; both commands validate their JSON aggregation contracts before
dispatch.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
@@ -1,11 +0,0 @@
---
category: Added
---
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
entries with lark-cli where the DingTalk backend supports equivalent
semantics, rejects malformed or missing collections instead of returning
false empty success, preserves truthful pagination, and requires stable
identifiers plus read-back or explicit terminal receipts for writes. Adds
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
capability review with documented platform boundaries.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
@@ -1,10 +0,0 @@
---
category: Added
---
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
`comment resolve`, `comment restore`, and the lightweight
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
the same `doc-comment` MCP capabilities; batch queries preserve input order
for repeated `topicId:commentKey` references, while reaction replies require
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
@@ -1,6 +0,0 @@
---
category: Added
---
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
@@ -1,14 +0,0 @@
---
category: Changed
---
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
strict response, identity, pagination, and real-data verification while
retaining historical CLI discovery and argument compatibility for commands
that remain unavailable to agents. Mailbox auto-resolution now accepts both
reviewed string and object response shapes, and Attendance date ranges cover
the complete requested end date without dropping cross-midnight punches whose
actual check time is inside the requested range. The schedule query remains
CLI-compatible but is withheld from the Agent catalog because its downstream
service returns a successful process exit with a null body for both populated
and empty ranges.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
@@ -1,5 +0,0 @@
---
category: Added
---
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
---
category: Fixed
---
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
@@ -1,5 +0,0 @@
---
category: Added
---
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
-9
View File
@@ -19,12 +19,3 @@
# Cache directory (optional, defaults to ~/.dws/cache)
# DWS_CACHE_DIR=
# Agent integration metadata (optional; ordinary non-plugin MCP requests only)
# DWS_AGENT_PRODUCT=example-agent
# DWS_AGENT_HOST=cloud
# DWS_AGENT_VER=0.1.5
# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
# The outer single quotes above are shell syntax and are not part of the value.
# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real
# tokens in committed files or use this metadata alone for authentication.
+4
View File
@@ -0,0 +1,4 @@
# Default code owners for all files
# These users will be automatically requested for review on PRs.
* @DingTalk-Real-AI/cli-maintainers
+4 -32
View File
@@ -3,43 +3,15 @@
- What changed?
- Why is this change needed?
## Risk tier
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
packaging, or interface behavior changed
- [ ] Standard: ordinary implementation change with a stable package graph
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
platform, auth/keychain, installer, packaging, release, transport, recovery,
or another fail-closed infrastructure change
## Verification
Record the smallest targeted evidence that proves the changed behavior. Do not
repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
`N/A`)
- [ ] Full local suite run because the change is high-risk (optional for other
tiers; record command/result or `N/A`)
- [ ] `make build`
- [ ] `make lint`
- [ ] `make test`
- [ ] `make policy`
- [ ] `./scripts/policy/check-generated-drift.sh`
(when generator inputs or generated artifacts may change)
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
- [ ] `./scripts/release/verify-package-managers.sh`
(after `make package`, if packaging or installer surfaces changed)
## Notes
- Any risks, follow-up work, or intentional scope cuts
The repository automatically requests one eligible peer reviewer, including
after a new head push when another review is needed. Once the latest push has
peer approval and all nine required checks are current and green, auto-merge
completes the PR; authors do not need to coordinate a separate routine merge.
-6
View File
@@ -1,6 +0,0 @@
paths:
.github/workflows/release.yml:
ignore:
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
# bundled workflow schema has not caught up with the platform syntax.
- 'unexpected key "queue" for "concurrency" section'
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 100.0%"><title>coverage: 100.0%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4b0"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">100.0%</text><text x="865" y="150" fill-opacity=".3" textLength="430">100.0%</text></g><text x="865" y="140" textLength="430">100.0%</text></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

-61
View File
@@ -1,61 +0,0 @@
# /eval 自助触发允许名单
#
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
#
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
aftersss
notable-open
EdgarWang0925
ayunya
yutongshe
qingyang1014
caiTriumph
xlb1130
Anonymity-0
FuShu-Yang
guimingyue
AlwaysLee
TaoJikun
zengyoulingzyl-stack
liyuan333
huangyoo
lifeihong
nitonitori
cywan1998
gangwn
junlonghuo2
aqruan
Freda0909
ShawnWhite777
PeterGuy326
abucraft
pengzhihan47-star
rainyak8
gongrongyun
huangyuanzhuo-coder
ybcstudy
bigqy
liwang-ai
meng93
wxianfeng
Patrick-Star-CN
rossluo28-hz
dxy704330469
gtezg30062
Neige-Premaire
zhuoyu20
avicii-chen
typefield
Haofeng0705
Huwenjiao
liuzeyang
maoqxxmm
FloralTide
lingyun9833
dxb121
C0922
xiaoji121
H3java
-285
View File
@@ -1,285 +0,0 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
-148
View File
@@ -1,148 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const {
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./reviewer-routing');
function route(files, author = 'author', latestPusher = author) {
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
}
{
const result = route(['internal/helpers/chat_toolbar.go']);
assert.deepEqual(result.reviewers, ['wxianfeng']);
assert.equal(result.requiredReviewers, 1);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
}
{
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['.github/workflows/ci.yml']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
assert.equal(result.reason, 'cross_or_sensitive');
}
{
const result = route(['internal/auth/login.go'], 'hlzjsong');
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/upgrade/downloader.go']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['pkg/edition/edition.go']);
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
}
{
const result = route(['internal/helpers/leaf_dispatch.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['docs/unknown-area.md']);
assert.deepEqual(result.reviewers, []);
assert.equal(result.reason, 'unknown_paths');
}
async function testSingleReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['wxianfeng'],
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 1,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('cannot request primary'), {status: 422});
}
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['typefield']);
assert.equal(result.satisfiedReviewers.length, 1);
}
async function testTwoReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['haofeng0705', 'wxianfeng'],
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 2,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('temporary failure'), {status: 503});
}
return true;
},
});
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
assert.equal(result.satisfiedReviewers.length, 2);
}
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
const attempts = [];
const result = await requestReviewersWithFallback({
candidates: ['wxianfeng', 'typefield'],
requiredReviewers: 1,
satisfiedReviewers: ['typefield'],
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng']);
assert.deepEqual(result.requested, ['wxianfeng']);
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
}
Promise.all([
testSingleReviewerFallback(),
testTwoReviewerFallback(),
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
])
.then(() => console.log('reviewer routing policy tests passed'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
-131
View File
@@ -1,131 +0,0 @@
name: Code Admission — AI Behavior
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches:
- main
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
ai-behavior-check:
name: AI Behavior
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Deliberately do not check out or execute pull-request code here.
# pull_request_target keeps this policy anchored to the base branch.
- name: Check AI-generated PR boundaries
uses: actions/github-script@v7
with:
script: |
const pullRequest = context.payload.pull_request;
const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha;
const setStatus = (state, description) =>
github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha,
state,
context: 'AI Behavior',
description,
});
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
if (context.eventName === 'push') {
await setStatus('success', 'Not applicable to the protected main push');
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
return;
}
try {
const expectedHead = pullRequest.head.sha;
const expectedBase = pullRequest.base.sha;
const currentPull = async (phase) => {
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
throw new Error(
`Pull request revision changed during ${phase}: ` +
`expected base/head ${expectedBase}/${expectedHead}, ` +
`got ${pull.base.sha}/${pull.head.sha}`
);
}
return pull;
};
const before = await currentPull('pre-policy check');
const labels = before.labels.map(({ name }) => name);
if (!labels.includes('ai-generated')) {
await setStatus('success', 'Not labeled ai-generated');
core.notice('Not an ai-generated PR; no AI-only policy applied.');
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
await currentPull('post-policy check');
const maxChangedFiles = 30;
if (files.length > maxChangedFiles) {
await setStatus(
'failure',
`Changes ${files.length} files; limit is ${maxChangedFiles}`
);
core.setFailed(
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
);
return;
}
const isProtectedPath = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/ci/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
filename === '.goreleaser.yaml' ||
filename === 'Makefile'
);
const protectedPaths = [...new Set(
files
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
.filter(isProtectedPath)
)];
if (protectedPaths.length > 0) {
await setStatus('failure', 'Modifies protected release/CI infrastructure');
core.setFailed(
'AI-generated PR modifies protected release/CI infrastructure:\n' +
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
'\nSplit these changes into a human-owned PR with explicit review.'
);
return;
}
await setStatus(
'success',
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
);
core.notice(
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
);
} catch (error) {
await setStatus('error', 'Could not evaluate the exact pull request revision');
throw error;
}
+75 -1782
View File
File diff suppressed because it is too large Load Diff
-296
View File
@@ -1,296 +0,0 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
on:
issue_comment:
types:
- created
permissions: {}
concurrency:
group: eval-dispatch-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
dispatch:
name: Dispatch internal evaluation
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/eval')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
- name: Verify commenter dispatch authorization
env:
GH_TOKEN: ${{ github.token }}
COMMENTER: ${{ github.event.comment.user.login }}
PR_AUTHOR: ${{ github.event.issue.user.login }}
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
run: |
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
permission_json="$(curl --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
- name: Parse /eval command
id: parse
continue-on-error: true
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: python3 scripts/ci/eval_comment_parse.py
- name: Reply usage on parse failure
if: steps.parse.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
id: pr
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
pr_json="$(curl --fail --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
printf '%s' "$pr_json" \
| python3 scripts/ci/eval_dispatch_guard.py head \
>> "$GITHUB_OUTPUT"
- name: Create dispatch placeholder
id: placeholder
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
| jq -er \
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
)"
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
- name: Build dispatch request manifest
env:
REPOSITORY_ID: '1187709537'
REPOSITORY: ${{ github.repository }}
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
ACTOR_ID: ${{ github.event.comment.user.id }}
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
SOURCE_BODY: ${{ github.event.comment.body }}
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
run: |
set -euo pipefail
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
echo "unexpected repository: ${REPOSITORY}" >&2
exit 1
fi
for value in \
"$REPOSITORY_ID" \
"$WORKFLOW_ID" \
"$RUN_ID" \
"$RUN_ATTEMPT" \
"$SOURCE_COMMENT_ID" \
"$DISPATCH_COMMENT_ID" \
"$ACTOR_ID" \
"$PR_NUMBER"; do
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
echo "dispatch manifest contains a non-canonical identifier" >&2
exit 1
fi
done
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "dispatch manifest contains an invalid PR head SHA" >&2
exit 1
fi
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
source_body_sha256="${hash_output%% *}"
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
echo "failed to hash source comment" >&2
exit 1
fi
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
umask 077
jq -n \
--arg repository_id "$REPOSITORY_ID" \
--arg repository "$REPOSITORY" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg source_comment_id "$SOURCE_COMMENT_ID" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg actor_id "$ACTOR_ID" \
--arg actor_login "$ACTOR_LOGIN" \
--arg pr_number "$PR_NUMBER" \
--arg pr_head_sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases_ref "$CASES_REF" \
--arg source_body_sha256 "$source_body_sha256" \
--arg idempotency_key "$idempotency_key" \
'{
schema_version: 1,
repository_id: $repository_id,
repository: $repository,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
source_comment_id: $source_comment_id,
dispatch_comment_id: $dispatch_comment_id,
actor_id: $actor_id,
actor_login: $actor_login,
pr_number: $pr_number,
pr_head_sha: $pr_head_sha,
products: $products,
cases_ref: $cases_ref,
source_body_sha256: $source_body_sha256,
idempotency_key: $idempotency_key
}' > "$MANIFEST_PATH"
- name: Upload dispatch request manifest
id: artifact
uses: actions/upload-artifact@v4
with:
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
path: ${{ runner.temp }}/eval-dispatch-request.json
if-no-files-found: error
retention-days: 1
overwrite: false
- name: Finalize dispatch marker
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
REPOSITORY_ID: '1187709537'
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
set -euo pipefail
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
echo "artifact marker contains a non-canonical identifier" >&2
exit 1
fi
artifact_digest="${ARTIFACT_DIGEST,,}"
if [[ "$artifact_digest" != sha256:* ]]; then
artifact_digest="sha256:${artifact_digest}"
fi
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "artifact marker contains an invalid digest" >&2
exit 1
fi
marker_json="$(
jq -nc \
--arg repository_id "$REPOSITORY_ID" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg artifact_id "$ARTIFACT_ID" \
--arg artifact_digest "$artifact_digest" \
'{
schema_version: 1,
repository_id: $repository_id,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
dispatch_comment_id: $dispatch_comment_id,
artifact_id: $artifact_id,
artifact_digest: $artifact_digest
}'
)"
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
--arg comment_id "$DISPATCH_COMMENT_ID" \
--arg body "$body" \
'((.id | tostring) == $comment_id) and (.body == $body)' \
> /dev/null
- name: Mark dispatch preparation failure
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+10 -56
View File
@@ -1,5 +1,4 @@
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
@@ -11,29 +10,22 @@ on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
inputs:
sync_release_version:
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
required: false
type: string
concurrency:
group: gitee-code-mirror
cancel-in-progress: false
jobs:
mirror:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout main history
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
@@ -44,8 +36,8 @@ jobs:
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
@@ -77,45 +69,7 @@ jobs:
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
- name: Download GitHub Release assets
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
echo "📥 Downloading release assets for ${VERSION}"
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist/
- name: Verify release artifacts
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
run: |
set -eu
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
./scripts/release/verify-release-artifacts.sh "$VERSION"
- name: Sync release assets to Gitee
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DIST_DIR: ${{ github.workspace }}/dist
run: |
set -eu
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
./scripts/release/sync-to-gitee.sh
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+4 -6
View File
@@ -1,9 +1,8 @@
name: Main Integration — 主干集成
name: Multi Profile E2E
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
@@ -15,7 +14,7 @@ concurrency:
jobs:
multi-profile-e2e:
name: Multi-profile E2E
name: Multi Profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -37,7 +36,7 @@ jobs:
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi-profile E2E"
echo "### Multi Profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
@@ -51,6 +50,5 @@ jobs:
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
include-hidden-files: true
if-no-files-found: ignore
retention-days: 3
-38
View File
@@ -1,38 +0,0 @@
name: Main Integration — Wukong Overlay
on:
workflow_run:
workflows:
- CI
types:
- completed
permissions: {}
jobs:
notify-downstream:
name: Notify Wukong Overlay
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Trigger downstream CI
env:
UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }}
WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }}
WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }}
run: |
if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then
curl --fail --silent --show-error \
-X POST \
-F "token=$WUKONG_TRIGGER_TOKEN" \
-F "ref=main" \
-F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \
"$WUKONG_TRIGGER_URL"
echo "Downstream CI triggered."
else
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
fi
File diff suppressed because it is too large Load Diff
-301
View File
@@ -1,301 +0,0 @@
name: Reviewer routing
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
# pull_request_target deliberately runs only this workflow from the protected
# base branch. Never check out or execute pull-request code here.
permissions:
contents: write
pull-requests: write
concurrency:
group: reviewer-router-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
route:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted routing policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (
currentPull.head.sha !== eventHeadSha ||
currentPull.state !== 'open' ||
currentPull.draft ||
currentPull.base.ref !== 'main'
) {
core.info(
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
);
return null;
}
return currentPull;
}
const pullRequest = await getReadyEventPull('initial read');
if (!pullRequest) {
return;
}
const author = pullRequest.user.login.toLowerCase();
const headSha = pullRequest.head.sha;
const latestPusher =
context.payload.action === 'synchronize'
? context.payload.sender?.login?.toLowerCase()
: author;
async function routeReview() {
let changedFiles;
try {
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
);
changedFiles = [];
}
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
reviewer.toLowerCase() !== latestPusher,
);
if (eligible.length === 0) {
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
let reviews;
try {
reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
);
return;
}
const latestDecisionByLogin = new Map();
for (const review of reviews) {
const login = review.user?.login?.toLowerCase();
if (
!login ||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
review.state,
)
) {
continue;
}
const previous = latestDecisionByLogin.get(login);
if (!previous || review.id > previous.id) {
latestDecisionByLogin.set(login, review);
}
}
const currentHeadReviewers = new Set(
[...latestDecisionByLogin.values()]
.filter(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
)
.map(review => review.user.login.toLowerCase()),
);
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
const openPullRequests = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const openPullRequest of openPullRequests) {
for (const reviewer of openPullRequest.requested_reviewers || []) {
const candidate = eligible.find(
login => login.toLowerCase() === reviewer.login.toLowerCase(),
);
if (candidate) {
loads.set(candidate, loads.get(candidate) + 1);
}
}
}
} catch (error) {
core.warning(
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
);
}
const offset = pullNumber % eligible.length;
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
const staleChangeRequester = [...latestDecisionByLogin.values()]
.filter(review => review.state === 'CHANGES_REQUESTED')
.sort((left, right) => right.id - left.id)
.map(review =>
eligible.find(
reviewer =>
reviewer.toLowerCase() === review.user.login.toLowerCase(),
),
)
.find(Boolean);
const ranked = [...eligible].sort(
(left, right) =>
Number(right === staleChangeRequester) -
Number(left === staleChangeRequester) ||
loads.get(left) - loads.get(right) ||
tieOrder.get(left) - tieOrder.get(right),
);
const routing = resolveReviewRouting({
files: changedFiles,
author,
latestPusher,
fallbackReviewers: ranked,
});
const candidates = reviewerCandidates({
preferredReviewers: routing.reviewers,
fallbackReviewers: ranked,
eligibleReviewers: eligible,
});
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
core.info(
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
);
return;
}
core.info(
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
);
const satisfiedReviewers = new Set([
...currentHeadReviewers,
...[...existingRequestedReviewers].filter((reviewer) =>
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
),
]);
const requestResult = await requestReviewersWithFallback({
candidates,
requiredReviewers: routing.requiredReviewers,
satisfiedReviewers: [...satisfiedReviewers],
requestReviewer: async (reviewer) => {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return false;
}
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pullNumber,
reviewers: [reviewer],
});
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return true;
},
onFailure: (reviewer, error) => {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
},
});
if (requestResult.aborted) {
return;
}
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
}
async function enableAutoMerge() {
try {
const currentPull = await getReadyEventPull('auto-merge enable');
if (!currentPull) {
return;
}
if (currentPull.auto_merge) {
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
return;
}
await github.graphql(
`mutation EnableAutoMerge($pullRequestId: ID!) {
enablePullRequestAutoMerge(
input: {
pullRequestId: $pullRequestId
mergeMethod: MERGE
}
) {
pullRequest {
autoMergeRequest {
enabledAt
}
}
}
}`,
{pullRequestId: currentPull.node_id},
);
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
} catch (error) {
core.warning(
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
);
}
}
try {
await routeReview();
} catch (error) {
core.warning(
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
);
}
await enableAutoMerge();
@@ -0,0 +1,49 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
-148
View File
@@ -1,148 +0,0 @@
name: Withdraw release
run-name: Withdraw ${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)"
required: true
type: string
reason:
description: "Public, single-line withdrawal reason (8-300 characters)"
required: true
type: string
confirmation:
description: "Type WITHDRAW followed by a space and the exact version"
required: true
type: string
permissions:
contents: read
# Share the publication lock with release.yml. A withdrawal and a publication
# must never mutate channel pointers concurrently.
concurrency:
group: dws-release-publication
cancel-in-progress: false
jobs:
withdraw:
name: Withdraw release from every distribution channel
environment: release-withdrawal
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
actions: read
contents: write
steps:
- name: Verify withdrawal environment protection
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: "release-withdrawal" },
);
const reviewerRule = response.data.protection_rules.find(
(rule) => rule.type === "required_reviewers",
);
if (
!reviewerRule ||
reviewerRule.prevent_self_review !== true ||
!Array.isArray(reviewerRule.reviewers) ||
reviewerRule.reviewers.length === 0
) {
core.setFailed("release-withdrawal must require a reviewer and prevent self-review");
return;
}
if (response.data.deployment_branch_policy?.protected_branches !== true) {
core.setFailed("release-withdrawal must allow only protected branches");
}
if (response.data.can_admins_bypass !== false) {
core.setFailed("release-withdrawal must not allow administrator bypass");
}
- name: Require the exact current official default-branch commit
uses: actions/github-script@v7
with:
script: |
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
const defaultBranch = context.payload.repository.default_branch;
if (context.eventName !== "workflow_dispatch") {
core.setFailed("release withdrawal accepts workflow_dispatch only");
return;
}
if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) {
core.setFailed(`release withdrawal is restricted to ${expectedRepository}`);
return;
}
if (context.ref !== `refs/heads/${defaultBranch}`) {
core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`);
return;
}
const branch = await github.rest.git.getRef({
...context.repo,
ref: `heads/${defaultBranch}`,
});
if (branch.data.object.sha !== context.sha) {
core.setFailed(
`default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`,
);
}
- name: Check out trusted withdrawal tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for npm channel withdrawal
uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Withdraw immutable release and roll back channels
id: withdrawal
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WITHDRAW_VERSION: ${{ inputs.version }}
WITHDRAW_REASON: ${{ inputs.reason }}
WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }}
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
./scripts/release/withdraw-release.sh \
"$WITHDRAW_VERSION" \
"$WITHDRAW_REASON" \
"$WITHDRAW_CONFIRMATION"
- name: Report withdrawal boundary
if: ${{ always() }}
env:
VERSION: ${{ inputs.version }}
RESULT: ${{ steps.withdrawal.outcome }}
run: |
{
echo "### Release withdrawal: ${VERSION}"
echo
echo "- Workflow result: ${RESULT}"
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
} >> "$GITHUB_STEP_SUMMARY"
-29
View File
@@ -19,11 +19,6 @@ test/cli_compat/testdata/
/internal/compat/testdata/*
.gitignore
.worktrees/
.qoder/
_logs/
_docs/
_output/
vendor/
# Secrets & credentials
.env
@@ -47,29 +42,5 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
/auto-test/
/eval-runs/
/.qoder/
.vercel
.env*
# Local Go coverage output
/coverage.txt
/coverage-base.txt
/coverage-policy.txt
/coverage.html
dwsbin
# Local shortcut eval / real-backend capture artifacts — may contain real PII
# (employee names/emails, userIds, conversation & message IDs). Never commit.
/docs/shortcut-real-read-results.json
/docs/shortcut-real-write-results.json
/docs/shortcut-comparison.html
/docs/shortcut-gsb-eval.*
/scripts/run_shortcut_real_read_matrix.py
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
/cmd_param_aliases
+8 -5
View File
@@ -1,14 +1,19 @@
# GoReleaser configuration for dws
# Docs: https://goreleaser.com
#
# To release, use scripts/release/release.sh. It seals main, validates the
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
# To release:
# git tag -a v0.1.0 -m "Release v0.1.0"
# git push origin v0.1.0
#
# To test locally (no publish):
# goreleaser release --snapshot --clean
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: dws
@@ -62,9 +67,7 @@ release:
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
name: dingtalk-workspace-cli
# Keep the release private until post-processing has replaced the Darwin
# archives and verified every finalized asset digest.
draft: true
draft: false
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
-51
View File
@@ -1,51 +0,0 @@
name: Gitee Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release tag to build on Gitee, e.g. v1.0.48"
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install packaging tools
run: |
set -eu
sudo apt-get update
sudo apt-get install -y zip unzip curl
- name: Install rcodesign
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Build and publish Gitee release
env:
VERSION: ${{ inputs.version || github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
run: ./scripts/release/build-and-publish-gitee.sh
-615
View File
@@ -1,615 +0,0 @@
# Repository Agent Guide
This file applies to the entire repository. Keep changes scoped, preserve
unrelated work, and use `gofmt` for every modified Go file.
## Build and test
- Build: `make build` (wraps `scripts/dev/build.sh` → `go build -o dws ./cmd`; bare `go build ./cmd` fails because output name `cmd` collides with the directory)
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
- Param aliases generate: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`; Catalog is not generated)
- Optional diagnostic MCP dump (not a Schema pin): `make fetch-mcp-metadata` (requires `dws auth login`; writes under `artifacts/`)
- Check generated drift + assembly determinism: `./scripts/policy/check-generated-drift.sh`
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
- Coverage-gate test naming: tests that carry coverage for the macOS platform
gate must be named `TestCrossPlatformCoverage*` (or `TestAllShortcuts*`);
`scripts/policy/run-platform-coverage-gate.sh` only selects those prefixes,
so a covering test with any other name silently leaves its target uncovered.
- Package-var injection seams (e.g. `pipelineBuildEffectiveRegistry`): swap
them in tests only via `testseam.Swap(t, &seam, stub)` from
`internal/testseam` — it restores the previous value through `t.Cleanup`
structurally. Like the manual pattern it replaces, Swap mutates global state
and is **not** safe for `t.Parallel` tests.
- Cross-package test helpers (e.g. `StoreProductDeclRawForTest`) live in
per-package `fortest.go` files, never scattered through production files;
the `ForTest` suffix is the boundary and production code must not call them.
Schema Catalog delivery is **声明即 Catalog**: production assembles via
`RegisterSchemaSourceRoot` → `ResolveSchemaBuild` (factory registered in
`internal/app`). There is no
`cmd_schema_catalog` `//go:generate` delivery step. `dws schema -f json` remains
the wire projection. `cmd_schema_catalog` produces CI/local dumps only;
`internal/cli/schema_catalog/`, `internal/cli/schema_meta_index.gob`, and
`internal/cli/schema_meta_index.json` must not be committed. `schema_agent_metadata/` is retired: if that directory
(or `schema_agent_metadata_audit.json`) is present, policy fails.
Command identity is no longer a file input: it is collected from
`ContractFinal.Identity` on the live Cobra leaves
(`internal/cli/schema_identity_collect.go` → `BuildEffectiveCommandRegistry`).
The reviewed `schema_command_registry/` was retired together with that
switchover and must not reappear; identity changes happen by editing the leaf
declaration. The remaining **reviewed inputs** under `internal/cli` (see Agent
Schema contract) keep separate authorities — do not merge them with
`param_concepts.json` or promote any of them into Catalog declaration.
## Command framework declaration
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
- Package seam:
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
- ContractFinal cobra store + Register → `internal/corecmd/contractfinal` (framework-owned)
- homology gates → `internal/cli/homology`
- Catalog assembly / `ResolveMeta` (`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`); go:embed only for reviewed inputs → `internal/cli` root (package-local aliases for annotate/store APIs live in `runtime_schema_seam.go`; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed — import `corecmd/*` directly)
- **Hard rule**: `internal/corecmd` (and its subpackages) must **not** import any `internal/cli` package
- Authoring tiers (current, not aspirational):
- **Tier1** — `corecmd.New` / `helpers.NewLeafCommand` (fully managed declare + execute)
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
- **Do not add** new production `AnnotateRuntimeRisk` / `AnnotateRuntimeGate`
(`runtime_gate`) call sites; migrate leaves to declared `Safety` /
`ContractDecl` instead. Existing annotate sites may remain until migrated.
## flag / help / schema homology
- Decision (path A — Contract/LeafSpec is CLI-surface authority **and must embed into Schema**): `docs/flag-help-schema-homology.md`
- Hard rule: every help/Schema fact is **declared** **or** **annotated**; never inference-only (§1.1–§1.3; framework §5.0).
- Embed path: `corecmd.New` → `dws.schema.*` annotations → Schema catalog assembly
- MCP metadata must not create CLI flags; optional 1:1 passthrough is a gated subset only.
- Gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §3–§4). `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` are on the `check-schema-catalog.sh` policy whitelist; remaining IDs land incrementally.
## Agent Schema contract
The Schema data flow is one way:
```text
1. app.NewRootCommand()
└─ builds the real Cobra command tree and flags
└─ leaf Safety / Contract / contract.ParamDecl declare ContractFinal (declare-or-annotate)
2. CollectIdentitySpecs (ContractFinal.Identity on live Cobra leaves)
└─ forms EffectiveCommandRegistry
└─ binds exactly to real Cobra leaves and aliases
3. Parameter resolution
Cobra flags
+ contract.ParamDecl.Property / native annotations (primary property authority)
+ schema_parameter_mapping_ledger.go (mapping_exclusions / removals only;
active bindings JSON retired after Track 1 Phase 2)
└─ produces ParameterSpec and constraints
4. Agent and interface semantics
ProductDecl + leaf ContractFinal Selection / Safety / Interface
+ contract.ParamDecl (interface_type / property)
└─ resolves Agent metadata by source precedence
Markdown is evidence only; it is not concatenated into final prose
└─ schema_hints/ and schema_mcp_metadata.json are fully retired
5. One typed hub
BoundCommandRegistry
+ ParameterSpec
+ Agent metadata
+ Interface metadata
└─ resolves every command exactly once into ToolSpec
└─ aggregates SchemaRegistry + SchemaIndex
└─ ResolveSchemaBuild assembles at runtime; deliverySchemaCatalog wraps it (lazy, sync.Once)
6. Runtime delivery (no generate-written Catalog authority)
SchemaRegistry
└─ dws schema list/product/group/leaf/--all (-f json wire)
└─ ResolveMeta projects Identity/Safety/Selection from the same registry
└─ CI may dump Catalog via cmd_schema_catalog for jq gates / determinism
```
**Reviewed inputs / 评审输入** (organizational family under `internal/cli`;
parallel peers, not one merged authority). These are assembly inputs only —
never Catalog declaration authority, never leaf `Contract` / `ProductDecl`
substitutes. Keep them side-by-side; do **not** fold one into another:
| Input | Path | Owns |
|---|---|---|
| Command identity | collected from `ContractFinal.Identity` on live Cobra leaves (`schema_identity_collect.go`; not a file input) | stable identity, primary CLI path, aliases, navigation |
| Param concepts | `param_concepts.json` (+ `.schema.json`) | argv synonym / concept dictionary (reduced to `param_aliases_generated.go`) |
| Exclusions | `schema_command_exclusions.go` | exact reviewed CLI paths excluded from Schema (non-empty reason) |
| Mapping ledger | `schema_parameter_mapping_ledger.go` | `mapping_exclusions` / removals (CLI flags with no direct RPC property); active bindings JSON retired |
`schema_mcp_metadata.json` is retired and must not reappear. Interface facts
(`interface_ref`, `interface_type`, …) declare on leaf `Contract` /
`contract.ParamDecl`. Retiring the pin cleared MCP-sourced `interface_type`
values from the wire; schema-compat deliberately accepts clearing (missing =
unknown for consumers) while still rejecting any change to a different
non-empty value. Re-populating a value requires an explicit `ParamDecl`
declaration, not a new pin.
**Aliases are three distinct layers** (do not conflate):
| Layer | Owns |
|---|---|
| `FlagSpec.Aliases` / Cobra flag aliases | executable flag synonyms on a leaf |
| `ContractFinal.Identity` `aliases` | reviewed CLI-path aliases for the same command identity |
| `param_concepts.json` | argv synonym / concept dictionary (central preparse normalization) |
**Visibility vs exclusions:** collected identity `visibility` is dormant (all
entries default `public`); “runnable but not Agent-visible” belongs in
`schema_command_exclusions.go`, not new `visibility` values. Native identity
annotations are consistency assertions only — they must agree with the
collected identity and never materialize or override it.
Leaf declare (`Contract` / `ParamDecl` / `Safety` / `ProductDecl`) and the live
Cobra tree remain separate from this table: declare owns semantics; Cobra owns
executability and flags.
After binding there is no second identity source and no identity precedence
winner. The binder must reject a missing/non-runnable Cobra path, an alias
collision, and any native identity annotation that disagrees with the effective
registry. A missing native identity annotation is allowed because annotations
are implementation-side assertions, not identity fallbacks.
The assembler resolves every bound command exactly once into one `ToolSpec`.
CI determinism (`check-schema-assembly.sh`) and policy jq gates consume a
fresh assembly dump; runtime consumes the same `ResolveSchemaBuild` path via
`RegisterSchemaSourceRoot`. Neither path may reopen annotations, merge source
records, or use a previous Catalog JSON as a source.
### Assembly vs consumption
**Assembly** (declare → typed registry; CI + runtime):
- Runtime entry: `RegisterSchemaSourceRoot` (`internal/app`) →
`ResolveSchemaBuild` / `deliverySchemaCatalog` (lazy, sync.Once).
- CI tool: `cmd_schema_catalog` dumps an assembled Catalog for jq/determinism;
it is **not** a `//go:generate` or committed delivery step.
- `gen.go` only generates `param_aliases_generated.go`.
- Inputs: **reviewed inputs** (param_concepts / exclusions / mapping ledger —
see table above) + ProductDecl/ContractFinal (identity is collected from
`ContractFinal.Identity`) + live Cobra tree.
`schema_hints/`, `schema_agent_metadata/`, `schema_command_registry/`, and
`schema_mcp_metadata.json` must not reappear.
- Gates: `make generate-schema` (param aliases + assembly determinism),
`check-generated-drift.sh`, `check-schema-catalog.sh`.
**Consumption** (runtime, unified API):
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
in `internal/cli/command_meta.go` — projected from the assembled registry
when the app factory is registered.
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
agent selection, future skill generation; `dws schema` uses the same
assembled Catalog (`-f json` wire unchanged).
- `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
alignment as permission to make an unversioned wire-format change.
The identity collected from `ContractFinal.Identity` (via
`CollectIdentitySpecs`) is the sole source of stable command identity and
navigation. The executable Cobra tree remains the source of truth for whether
a CLI path exists, is runnable, and which flags it accepts. Schema coverage is
bidirectional:
1. Every final `SchemaRegistry` tool, including its serialized Catalog
projection, must resolve to an executable Cobra command.
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
an exact, reviewed exclusion with a non-empty reason in
`internal/cli/schema_command_exclusions.go` (central Go groups; not JSON).
Do not use prefix or wildcard exclusions: they can silently hide future
commands. Remove an exclusion when its command enters Schema; stale, invalid,
or duplicate exclusions must fail generation and CI.
When adding or changing an Agent-visible command, review all relevant inputs:
- Leaf `ContractFinal.Identity` for canonical identity, primary CLI path,
aliases, and stable navigation. Identity is collected from the live Cobra
leaves (`CollectIdentitySpecs`); there is no separate identity file. Invalid
canonical paths, alias collisions, stale paths, and drift fail collection,
binding, and policy.
- Leaf `Safety` / `Contract` (`corecmd.ContractDecl`) / `contract.ParamDecl`
(helpers `LeafSpec` or shortcut `Contract`) for parameter facts, interface
disposition, safety, and Agent selection prose. Delivered provenance is
`contract_final` from `corecmd.contract` (description may stamp `cobra_help`
when Cobra Long wins). Product routing uses `ProductDecl`
(`internal/corecmd/contract`; provenance label remains `cli.product_decl`).
- `internal/cli/schema_hints/` is fully retired. Do not reintroduce HintFiles,
audit JSON, or `imported/` baselines; declare on ProductDecl / the owning
leaf instead.
- Native Runtime Schema identity annotations, when present, as consistency
assertions against `EffectiveCommandRegistry`. They must agree exactly and
must never materialize, infer, or override registry identity.
- Flag-to-interface property mappings and required/default semantics.
- Do not expect generate-written Catalog delivery. Run
`make generate-schema` only to refresh param aliases and prove assembly
determinism. Do not expect or commit `schema_agent_metadata/`.
Run the reverse-completeness tests whenever the Cobra tree changes. A command
that works through `dws <path>` but cannot be found through the matching
`dws schema` lookup is a contract failure unless it has a reviewed exact
exclusion.
`RegisterSchemaHints` / `ToolSchemaHint` overlays are fully removed. Parameter
and selection facts must be declared on the owning leaf (`contract.ParamDecl` /
`Contract`) or via `ProductDecl`; do not reintroduce overlay registries.
For Agent-authored selection edits:
1. Confirm the exact command and flag names in the current Cobra tree.
2. Declare selection prose on the owning leaf (`Contract.Selection` /
`DeclareLeafMetadata`) and product routing via `ProductDecl`; declare
safety / parameters / interface on the same leaf.
3. Do not copy generated Catalog fields into source inputs.
4. Run generation, drift, Schema policy, and the focused CLI tests before
proposing the change.
## Agent curation workflow
Use this workflow when refreshing Agent selection prose and confirmation
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
Skill Markdown into Catalog fields.
Human-authored inputs:
| Block | Path | Owns |
|---|---|---|
| **declaration** | helpers / shortcut `Safety` + `Contract` / `contract.ParamDecl` + `ProductDecl` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / parameter facts / selection prose (`contract_final`) |
`schema_hints/` is fully retired. Do not reintroduce HintFiles or audit JSON.
### Goals
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
sibling-command routing, and outcome shape — not a restatement of the
summary. Delivered Catalog provenance is `contract_final` from leaf
`Contract.Selection` / `ProductDecl`.
2. **Safety** follows Runtime: `confirmation=user_required` when the leaf
Contract/Safety (or remaining `runtime_gate` annotate) requires a user gate
(for example `confirm_delete`, `typed_yes`, `confirm_dangerous`).
3. **Parameter facts** are declared on the leaf (`contract.ParamDecl` /
`Contract.Parameters` / FlagSpec). Do not reintroduce HintFile or
`RegisterSchemaHints` overlays.
### Authoring
For every curated tool:
1. Declare safety/interface/parameters/selection on the owning leaf
(`DeclareLeafMetadata` / `Shortcut.Contract` / `contract.ParamDecl`) and product routing
via `ProductDecl` when needed.
2. Run `make generate-schema` (param aliases + assembly determinism). Do not
create or commit `schema_catalog/` or Schema meta-index fixtures.
### Pull live MCP descriptions (personal token)
Schema delivery no longer embeds a pinned MCP JSON. Prefer live Schema from a
logged-in personal session when reviewing interface facts before declaring them
on the leaf:
```bash
dws auth status # token_valid should be true
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
failure, fall back to Skill + Cobra Help, and record evidence
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
`make fetch-mcp-metadata` writes an optional diagnostic dump under `artifacts/`
only — do not commit it as a Schema pin.
Precedence when sources disagree: **Runtime/Cobra / leaf Contract > live MCP >
Skill (evidence only)**.
### Parallel product agents
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
- **Never** `git checkout` unrelated product files to “clean scope”.
### Regenerate and gates
```bash
make generate-schema
./scripts/policy/check-runtime-confirmation-truth.sh
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
```
`check-runtime-confirmation-truth.sh` compares live ContractFinal.Safety with the assembled ToolSpec `confirmation=user_required` and probes the runtime gate.
`schema_hints/` must stay absent.
Example rules (fail generation otherwise):
- At most two examples per tool; no `--yes` in stored examples.
- Examples must match live Cobra argv (path, flags, required groups).
- No shell comments in examples.
After generation, spot-check Catalog: selection and safety/interface
provenance are `contract_final` from ProductDecl / leaf declarations
(`user_required` must match Runtime confirmation gates).
`make generate-schema` refreshes `param_aliases_generated.go` and runs
assembly determinism (`check-schema-assembly.sh`). It does not rewrite a
committed Catalog as delivery authority — runtime reassembles from
declarations. Byte guards fail if generation mutates parameter-concept
inputs; policy fails if the retired `schema_command_registry/` reappears.
Selection prose may choose a more or less restrictive recommendation. It cannot
create a Cobra command or flag, change parameter facts, invent an
RPC/interface, alter safety metadata, or bypass command completeness. Examples
must use an executable primary/alias path and flags accepted by the live Cobra
command; never add `--yes` to stored examples.
Every example is always checked against its real `BoundCommand`: exact path,
accepted flags, Cobra required flags/positionals, and the effective
`require_one_of`, `require_together`, and `mutually_exclusive` constraints must
all pass before execution eligibility is considered. A missing required value,
constraint failure, runtime error, or MCP resolution error is a contract bug;
none is a valid reason to skip an example.
Example execution defaults to contract validation only. Runtime execution is
opt-in: an example enters `dry_run` only when its final `ToolSpec` publishes an
explicit reviewed dry-run capability. The test never injects `--yes`, and
`risk`/`confirmation` values do not manufacture preview support. A narrow
runtime precondition that cannot be derived from the typed contract may use an
exact zero-based `example_dispositions` entry with `mode=contract_only`,
`reviewed=true`, one of the schema-enumerated reason codes, and a concrete
non-empty reason. Such a disposition may only narrow an explicit dry-run
capability; it cannot turn an ordinary contract-only example into a skip.
Duplicate, missing, and out-of-range indexes fail validation. Never catch a
dry-run failure and dynamically downgrade it to `contract_only`.
Normal Go tests run the exhaustive contract gate. Run
`make test-schema-agent-examples` to additionally execute the eligible subset
through the real Cobra `--dry-run` path with isolated HOME and blocked proxies.
The test reports stable `total`, `contract`, `dry_run`, `contract_only`,
`reviewed_manual`, and per-reason counts; changing those counts requires a
review of the corresponding typed dry-run capability or manual disposition.
This target is also part of `make policy`.
Treat every tool `use_when` entry as a reviewed positive selection scenario
whose expected result is that tool's canonical path, and every `avoid_when`
entry as a reviewed negative scenario that must not choose that tool. The
deterministic gate derives a typed evaluation fixture from these same fields;
it requires exact tool coverage, a real runnable `BoundCommandRegistry`
primary command, at least one positive and negative assertion per tool, and no
literal contradictory expectations. It does not claim that string matching
proves natural-language understanding.
Semantic selection is an explicit opt-in live-model check. Run the smoke set
(one positive and one negative scenario per product) with
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestAgentSelectionArkLive -count=1`.
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
never calls a model; its blockers remain the reproducible fixture, binding,
example, provenance, and final-delivery facts.
The live evaluator sends only case IDs/scenarios plus one same-product
candidate table; expected/forbidden assertions stay local and must never be
included in the model prompt. Built-in Ark HTTPS bases are allowlisted. A
different HTTPS provider requires its exact base in
`DWS_AGENT_SELECTION_ALLOWED_BASE_URLS`; plaintext HTTP is accepted only for a
loopback test server so API credentials are never sent to an arbitrary clear
text endpoint.
## Safety metadata
Parameter and safety resolution is mostly source-precedence based and
value-neutral: do not choose a winner because one value looks stricter. A
higher-priority reviewed metadata/explicit source may intentionally raise or
lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
Preserve all candidates and the selected source in provenance, and fail
same-precedence conflicts rather than silently merging them.
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
final Agent projection must keep `required=true` and cannot be lowered by a
lower-precedence source. A higher-precedence declaration may still raise an
optional flag to required. `cli_required` continues to mirror the executable
Cobra marker.
For command-level description: **declare required, delivery Long may win**.
`ContractDecl.Description` is mandatory at construction (declaration evidence).
Catalog delivery prefers Cobra Long when present (provenance `cobra_help`,
resolution `cobra_help_preferred`); without Long, the declared Description is
delivered as `contract_final`. Title keeps declared ContractDecl /
ContractFinal first, then Cobra Short, then MCP metadata. This is one authority
chain with an explicit delivery preference — not two competing sources.
Generic RPC prose may remain an unselected provenance candidate (and
parameter-level `interface_description`); it must not overwrite a specialized
leaf's title or description.
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
winner value must exactly equal the delivered value. Checking only source,
count, presence, or hash is not a sufficient final-delivery invariant.
The same resolved `ToolSpec` must drive every projection. The full leaf payload
must equal the corresponding tool in `schema --all` and the full Catalog tool.
Overview/product/group summaries and Catalog summaries must equal
`ToolSpec.ToSummaryPayload()`. An alias lookup may change only the view fields
`cli_path` and `is_alias`; it must not re-resolve or mutate the command
contract.
This build-time rule is distinct from runtime drift handling. If shipped Help
and leaf Schema disagree, pass only flags accepted by Cobra. For conflicting
safety information, do not silently take the less restrictive behavior: use
the safer interpretation or stop and report the contract drift.
Do not infer one safety field from another. In particular, `effect=destructive`
or `risk=high` does not mechanically rewrite `confirmation`; the final
precedence winner for each field is authoritative. When
`confirmation=user_required`, obtain confirmation before adding `--yes`.
Keep CLI confirmation behavior and Schema metadata consistent, and add a
semantic regression test through the final embedded loader/query delivery
path; a generator unit test or JSON count alone is insufficient.
## Unified result Schema and performance
The unified runtime envelope and the per-command Schema result declaration are
related but distinct contracts:
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
`meta`) and derives it through `internal/output`. Business commands return a
`CommandResult`; they must not hand-author the outer JSON shape.
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
business value inside `data`. It may declare `outcomes`, `data_schema`, and
`sensitive_paths`. `Contract.Pagination` is a separate command capability
because pagination is emitted under envelope `meta`, not inside `data`.
- `outcomes` is the set of results a command may produce; it is not the outcome
of the current invocation. `data_schema` is a JSON Schema object for business
data and must not duplicate the framework envelope.
- Result declarations are delivered in the full leaf and in the reviewed
`--compact` Agent projection. Compact retains the normalized `result` object
verbatim but still omits provenance, interface bindings, and other audit-only
fields. Product/group summaries remain navigation views and need not repeat
every leaf Result. When an Agent needs return-shape facts, query the compact
leaf directly; do not load the whole full Catalog.
- A missing `result` means “no reviewed return-value declaration is published
for this leaf.” It does **not** prove that the runtime is legacy, and it must
not be filled by inference from examples, MCP samples, or previous command
output. Runtime rollout remains an internal per-command fact.
- The public contract has no `contract_version`, no `--output-contract`, and no
Agent-selectable protocol alias. Agents continue to request machine output
with `--format json`; migrated commands use the unified result directly and
unmigrated commands retain their current legacy output.
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
currently has representative Result coverage. Do not describe that as
repository-wide coverage. Any newly activated Agent-visible command should
add and test its Result declaration; the remaining pilot gaps should shrink,
not expand.
The compact/full leaf `result` object has one stable shape:
```json
{
"result": {
"outcomes": ["success", "pending", "partial_failure", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Stable resource ID"},
"name": {"type": "string", "description": "Display name"}
}
}
}
}
},
"sensitive_paths": ["credential.secret"]
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
Field rules:
| Field | Required | Contract |
|---|---|---|
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
Optional members are omitted, never emitted as `null`. A leaf without a
reviewed Result omits the entire `result` key. Compact must preserve the same
normalized Result value as the full leaf; it must not summarize, infer, rename,
or independently rebuild any Result field. Product/group summaries do not
aggregate child Result objects.
`pagination` is a sibling of `result`, not a child. It declares the canonical
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
Product response fields used to derive that metadata remain mapper internals;
they are not part of `result.data_schema`. Do not execute a second request to
derive pagination metadata.
Invalid result declarations fail closed during normalization: unknown or
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
sensitive paths, unsupported pagination kinds, attempts to override framework
meta paths, and an invalid cursor parameter must be rejected rather than
silently removed.
Full-leaf wire round trips must
preserve the normalized Result exactly. Do not commit generated Schema JSON as
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
from declarations.
### Performance model and rules
- Catalog construction is declaration-driven and cached through the existing
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
command invocation, per leaf lookup, or per renderer.
- Normalizing one Result declaration is linear in the size of that declaration.
Full `schema --all` is linear in tools + parameters + Result schema bytes and
is an audit/compatibility export, not the normal Agent discovery path.
Overview → compact product/group → compact leaf remains the normal route;
only the final leaf carries its Result declaration.
- Constructing a `CommandResult` defensively clones result data and validates
invariants; rendering is buffer-first and then writes once. Both CPU cost and
transient memory are O(payload size), with roughly one additional in-memory
rendered copy. This buys immutability and prevents partial JSON leakage, but
it is not free.
- Large list/search commands must use bounded pages and publish continuation
facts. The current emitter buffers one command result/page before publishing;
pagination is the memory bound. Continuous event streams are a separate,
command-specific protocol and are not described by `ResultSpec`.
- A `dual_validate` command must execute the business request exactly once,
validate a shadow unified result, and preserve legacy bytes. Never obtain
validation by issuing a second network or write request.
- Filters and alternate formats are render-time work over the same in-memory
result. They must not rerun the business operation or rebuild Schema.
- Performance changes must preserve the one-result, buffer-first, fail-closed,
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
improvement. For a material hot-path change, benchmark representative small
and page-sized payloads and report allocations/bytes as well as latency.
## Current Schema boundaries
- `schema list` remains a progressive overview. `schema --all` is the stable
full-export contract: every final `SchemaIndex` tool must contain its
complete leaf parameters, constraints, and safety semantics, including an empty
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, safety/confirmation semantics, and any reviewed `result`
contract. Full leaf fields such as `property`, `interface_ref`, and
provenance are audit facts. A conflict is contract drift, not permission to
guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
-1058
View File
File diff suppressed because one or more lines are too long
+9 -58
View File
@@ -27,9 +27,7 @@ notes that are intentionally kept out of the repository root.
## Local Checks
Run the verification commands that match the surface you changed before you
hand work back. The goal is useful, change-specific evidence, not a second
local execution of every CI job.
Run the verification commands that match the surface you changed before you hand work back.
Common repository checks already used here include:
@@ -38,74 +36,27 @@ Common repository checks already used here include:
./scripts/policy/check-open-source-assets.sh
go test ./...
make test
make test-plan
make lint
bash test/scripts/run_all_tests.sh --jobs 8
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/release/verify-package-managers.sh
git diff --check
```
Select the PR risk tier before choosing checks:
| Tier | Typical scope | Developer evidence | CI expansion |
|---|---|---|---|
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
Classification fails closed: an incomplete diff, package add/remove/rename, or
uncertain dependency graph selects the high-risk suite. Native changed-code
coverage is additionally selected for platform-sensitive code.
## Pull Request Checklist
1. Keep implementation and tests in sync.
2. Select the documentation-only, standard, or high-risk tier and run the
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
a complete local pass is warranted; it is not required for every ordinary
PR.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag or command-path migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
2. Run `./scripts/dev/ci-local.sh`.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Include verification evidence in your PR description.
## Submission Flow
1. Make the smallest atomic change that satisfies the task.
2. Keep doc edits factual and limited to implemented behavior.
3. Run the relevant verification commands.
4. Report the validation results and risk tier with the handoff.
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
peer reviewer, balancing the current open-review load and excluding the
author. A new head push re-enters the same routing flow when the latest
revision still needs review.
6. After the latest push has one peer approval and the exact nine required
contexts are current and green, auto-merge completes the PR. If `main`
advances first, strict status checks revalidate the branch; no separate
routine merge request is needed.
Contributors without repository write access stop at the PR flow. Explicitly
authorized collaborators with `write`, `maintain`, or `admin` access can use
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
to publish beta releases without manual approval. The same internal roles may
start a stable release, but a different repository administrator must approve
the `release-stable` Environment deployment before publication continues.
4. Report the validation results with the handoff.
-63
View File
@@ -1,63 +0,0 @@
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.59-beta.4"
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.59-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
-63
View File
@@ -1,63 +0,0 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+15 -226
View File
@@ -1,16 +1,6 @@
GO ?= go
DWS_PACKAGE_VERSION ?= 0.0.0-test
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
SCHEMA_CATALOG_OUTPUT ?= artifacts/schema_catalog
SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -18,32 +8,11 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
@printf " make package - Build all release artifacts locally\n"
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make policy - Run open-source asset and command-surface checks\n"
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
@printf " make release - Build and publish a release via goreleaser\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
@@ -53,178 +22,24 @@ rebuild:
@./scripts/dev/build.sh
test:
@DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./...
test-plan:
@./scripts/ci/test-packages.sh verify
test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
@./test/scripts/run_all_tests.sh
lint:
@./scripts/dev/lint.sh
format-check:
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \
if [ -n "$$unformatted" ]; then \
printf '%s\n' "$$unformatted"; \
printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \
exit 1; \
fi
fmt:
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@python3 scripts/run_chat_shortcut_live_audit_test.py
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
policy:
@./scripts/policy/check-open-source-assets.sh
@./scripts/policy/check-command-surface.sh --strict
edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
authoritative-interface-integrity:
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
coverage-gate-platform:
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
update-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --update
reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
mock-mcp-smoke:
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
test-schema-agent-examples:
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
# generate-schema refreshes param_aliases_generated.go and verifies that
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
# schema_agent_metadata/ and schema_hints/ must stay absent.
generate-schema:
@set -e; \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
command_fallbacks_guard=$$(mktemp); \
command_fallbacks_schema_guard=$$(mktemp); \
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
$(GO) generate ./internal/cli; \
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
rm -f internal/cli/schema_meta_index.json; \
if [ -e internal/cli/schema_command_registry ]; then \
printf '%s\n' 'retired schema_command_registry/ must not reappear after generation' >&2; \
exit 1; \
fi; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
exit 1; \
}; \
if [ -e internal/cli/schema_hints ]; then \
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
exit 1; \
fi; \
if [ -e internal/cli/schema_meta_index.json ]; then \
printf '%s\n' 'retired schema_meta_index.json must not remain after generation' >&2; \
exit 1; \
fi; \
./scripts/policy/check-schema-assembly.sh
# Optional local/CI dump of an assembled Catalog under artifacts/ by default.
# Override SCHEMA_CATALOG_OUTPUT and SCHEMA_META_INDEX_OUTPUT as needed. This
# is not a go:generate or production delivery step.
generate-schema-catalog:
$(GO) run -a ./internal/generator/cmd_schema_catalog \
-root . \
-output "$(SCHEMA_CATALOG_OUTPUT)" \
-meta-index "$(SCHEMA_META_INDEX_OUTPUT)"
fetch-mcp-metadata:
@printf ' %sFetching diagnostic MCP dump (not a Schema pin)%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
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
publish-homebrew-formula:
@./scripts/release/publish-homebrew-formula.sh
@@ -232,32 +47,6 @@ publish-homebrew-formula:
setup-hooks:
@git config core.hooksPath scripts/hooks 2>/dev/null || true
changelog-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
changelog-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
release-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
release-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
release:
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
@exit 2
goreleaser release --clean
@./scripts/release/post-goreleaser.sh
+50 -214
View File
@@ -70,17 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
</details>
@@ -93,30 +93,6 @@ How to pick:
npm install -g dingtalk-workspace-cli
```
Install the latest beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew** (macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
Install the keg-only Homebrew beta without replacing the stable Formula:
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
@@ -133,10 +109,6 @@ go build -o dws ./cmd # build to current directory
cp dws ~/.local/bin/ # install to PATH
```
Static endpoint data is generated from the Wukong baseline and committed in this
repository under `internal/syncdata`, so source builds do not require a sibling
data checkout.
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
</details>
@@ -180,46 +152,27 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
```bash
dws upgrade # interactive upgrade to latest version
dws upgrade --check # check for new versions without installing
dws upgrade --list # list stable release versions
dws upgrade --beta # upgrade to the latest beta pre-release
dws upgrade --check --beta # check the beta track without installing
dws upgrade --list --beta # list beta pre-release versions
dws upgrade --list # list all available versions
dws upgrade --version v1.0.7 # upgrade to a specific version
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
dws upgrade --rollback # rollback to the previous version
dws upgrade -y # skip confirmation prompt
```
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
### Six-channel post-release verification
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
<details>
<summary><strong>How it works</strong></summary>
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
| Flag | Description |
|------|-------------|
| `--check` | Check for updates without installing |
| `--list` | List available stable release versions with changelogs |
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
| `--list` | List all available versions with changelogs |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
| `--rollback` | Rollback to the previous backed-up version |
| `--force` | Force reinstall even if already on the latest version |
| `--skip-skills` | Skip skill package update |
@@ -283,32 +236,16 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk accounts at once, including multiple accounts in the same organization. A profile is uniquely identified by `corpId:userId`; the current profile decides which identity a command runs as.
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
```bash
dws auth login # add or refresh one account
dws profile list # list every logged-in account
dws profile switch <corpId:userId> # persistently switch; use - to toggle back
dws profile switch "<corpName>:<userName>" # friendly input; names must be unique
dws --profile <corpId> contact user search --query "..." # use that org's explicitly recorded current account
dws --profile <corpId:userId> contact user search --query "..." # use one exact account without changing the default
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
```
Selectors support `corpId:userId`, `corpId:userName`, `corpName:userId`, and `corpName:userName`. Friendly names are input aliases only; use the stable `profile` value returned by `profile list` for automation. Duplicate organization or account names fail with explicit `corpId:userId` candidates. If an organization has multiple accounts but no recorded current account, `--profile <corpId>` fails instead of choosing the first or most recently used account.
`currentProfile`, `previousProfile`, and per-organization defaults are stored as exact identities. `primaryProfile` remains in JSON only for compatibility and is not used for selection. `profile list` reads status and expiry from each real identity Token without refreshing it. `auth logout --profile <corpId>` removes all local accounts in that organization; an exact selector or local profile name removes one account.
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list profiles, group by `corpId`, and use the unique `isOrgCurrent=true` account for each organization. If a multi-account organization has no default, ask the user to choose an account first. Writes default to the current account — confirm both organization and account before cross-org writes.
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected account with `dws auth logout --profile <corpId:userId>`, or all accounts in one organization with `--profile <corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
</details>
@@ -329,9 +266,6 @@ dws auth status # confirm "Refresh Token: valid"
```
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
Windows export and import are intentionally rejected before credentials or
bundles are read: Windows stores credentials as DPAPI-protected HKCU Registry
values, and the current file-DEK bundle has no safe DPAPI-to-portable conversion.
</details>
@@ -366,46 +300,39 @@ dws contact user search --query "engineering" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### Command Help and Schema
### Schema Discovery
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
```bash
# Confirm that the command exists and inspect accepted flags
dws aitable record query --help
# Step 1: Discover all available products
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# Discover within a product, then inspect the selected leaf contract
dws schema aitable --compact
dws schema "aitable record query" --compact
# Step 2: Inspect target tool's parameter schema
dws schema aitable.query_records --jq '.tool.parameters'
# Execute the real business query
# Optional: inspect DingTalk authorization metadata for PAT planning
dws schema aitable.query_records --jq '.tool.auth'
# Step 3: Construct the correct call
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
### Agent Skills
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
# Install skills into current project (defaults to mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -415,31 +342,22 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# Interactive: prompts for mode + target agents
dws skill setup
# Preview the exact directories that mono setup would back up and replace
dws skill setup --mode mono --target all --dry-run
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
# Point at a local source tree (e.g. a fork or work-in-progress)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
| `--yes` | — | Skip confirmation prompts |
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
@@ -452,6 +370,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -479,89 +398,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
## Features
<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 covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance 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`.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# Or install the standalone multi skill from an existing dws installation
dws skill setup --mode multi -s event
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# Listen for messages that mention the current user
dws event +listen-im --kind at-me -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# Listen for all one-to-one or all group messages
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -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 messages, reads, and recalls from the same sender in one process
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all seven public OA approval events in one process
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
dws event stop <subscribe_id>
```
For one-to-one and specified-sender events, use exactly one target identity: `--user` for an internal `userId`, or `--open-dingtalk-id` for an `openDingtalkId`. The CLI does not infer or convert between these identity types.
| Feature | Details |
|---------|---------|
| 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 |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
<details>
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
@@ -643,7 +479,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -652,13 +488,14 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
```bash
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema --all # full export for CI/audit/baselines
dws schema # list all products and tools
dws schema aitable.query_records # view parameter schema
dws schema aitable.query_records --jq '.tool.required' # view required fields
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
dws schema --jq '.products[].id' # extract all product IDs
```
</details>
@@ -710,7 +547,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users, departments, labels, roster profiles and dismissals; create enterprises and enterprise accounts; invite employees |
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
@@ -738,7 +575,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
</details>
@@ -787,10 +624,9 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Reference & Docs
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
- [Changelog](./CHANGELOG.md) — release history and migration notes
+46 -208
View File
@@ -70,17 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
</details>
@@ -93,30 +93,6 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
npm install -g dingtalk-workspace-cli
```
安装最新 beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew**(macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
安装 Homebrew beta(keg-only,不覆盖稳定版):
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
@@ -134,7 +110,6 @@ cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
</details>
@@ -177,46 +152,27 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
```bash
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出正式 release 版本
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
dws upgrade --list --beta # 列出 beta 预发布版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version v1.0.7 # 升级到指定版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级
```
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
### 六渠道发布后验证
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
<details>
<summary><strong>工作原理</strong></summary>
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
| Flag | 说明 |
|------|------|
| `--check` | 仅检查更新,不安装 |
| `--list` | 列出正式 release 版本及更新日志 |
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
| `--list` | 列出所有可用版本及更新日志 |
| `--version` | 升级到指定版本(如 `v1.0.7`) |
| `--rollback` | 回滚到上一个备份版本 |
| `--force` | 强制重新安装,即使已是最新版本 |
| `--skip-skills` | 跳过技能包更新 |
@@ -280,32 +236,16 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉账号,同一组织也能保留多个账号。一个 profile 由 `corpId + userId` 唯一确定。
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
```bash
dws auth login # 新增或刷新一个账号
dws profile list # 列出全部账号,profile 字段是稳定的 corpId:userId
dws profile switch <corpId:userId> # 持久切换账号;用 - 切回上一个
dws profile switch "组织名:用户名" # 名称输入要求唯一
dws --profile <corpId> contact user search --query "..." # 使用该组织明确记录的当前账号
dws --profile <corpId:userId> contact user search --query "..." # 单次精确指定账号,不改默认账号
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
```
支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`。名称只用于输入,自动化应使用 `profile list` 返回的稳定 `profile`。组织名或用户名重名时会列出候选并报错;同组织多账号但没有明确当前账号时,只传组织也会报错,不会选择第一项或最近使用账号。
`currentProfile`、`previousProfile` 和组织默认账号都保存精确身份。`primaryProfile` 只为 JSON 兼容保留,不再参与选择。`profile list` 直接读取各身份 Token 计算状态和到期时间,不触发刷新。`auth logout --profile <corpId>` 退出该组织全部账号;精确选择器或本地 profile 名只退出一个账号。
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list`,每个组织使用唯一的 `isOrgCurrent=true` 账号;若多账号组织没有默认账号,先让用户指定账号。写操作默认只在当前账号执行——跨组织写之前先确认目标组织和账号。
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,优先使用 `dws auth logout --profile <corpId:userId>` 只清理受影响账号;只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
</details>
@@ -360,46 +300,36 @@ dws contact user search --query "张三" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### 命令帮助与 Schema
### Schema 发现
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
```bash
# 确认命令存在并查看当前接受的 flags
dws aitable record query --help
# 第一步:发现所有可用产品
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable --compact
dws schema "aitable record query" --compact
# 第二步:查看目标工具的参数结构
dws schema aitable.query_records --jq '.tool.parameters'
# 执行真实业务查询
# 第三步:构造正确的调用
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
# 安装 skills 到当前项目(默认 mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -409,31 +339,22 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
# 指定本地源目录(比如 fork 或正在改的版本)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
| `--yes` | — | 跳过确认提示 |
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
@@ -446,6 +367,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -473,89 +395,6 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
## 功能特性
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
只需要 event 能力时,可以使用官方便捷安装脚本:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# 或在已有 dws 环境中安装独立的 multi skill
dws skill setup --mode multi -s event
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# 监听当前用户被 @ 的消息
dws event +listen-im --kind at-me -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# 监听所有单聊或所有群消息
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -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 +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部七个公开 OA 审批事件
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
dws event stop <subscribe_id>
```
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
| 特性 | 说明 |
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
<details>
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
@@ -637,7 +476,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -646,13 +485,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
```bash
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema --all # CI/审计/基线的全量导出
dws schema # 列出所有产品和工具
dws schema aitable.query_records # 查看参数 Schema
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
dws schema --jq '.products[].id' # 提取所有产品 ID
```
</details>
@@ -699,7 +538,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职;创建企业、企业账号及邀请员工 |
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
@@ -727,7 +566,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
</details>
@@ -778,10 +617,9 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 参考与文档
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
-63
View File
@@ -1,63 +0,0 @@
class __CLASS_NAME__ < Formula
desc "__DESCRIPTION__"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "__VERSION__"
license "Apache-2.0"
__KEG_ONLY_LINE__
on_macos do
if Hardware::CPU.arm?
url "__DARWIN_ARM64_URL__"
sha256 "__DARWIN_ARM64_SHA256__"
else
url "__DARWIN_AMD64_URL__"
sha256 "__DARWIN_AMD64_SHA256__"
end
end
on_linux do
if Hardware::CPU.arm?
url "__LINUX_ARM64_URL__"
sha256 "__LINUX_ARM64_SHA256__"
else
url "__LINUX_AMD64_URL__"
sha256 "__LINUX_AMD64_SHA256__"
end
end
resource "skills" do
url "__SKILLS_URL__"
sha256 "__SKILLS_SHA256__"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
__CHANNEL_CAVEAT__
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+38 -7
View File
@@ -1,5 +1,5 @@
class __CLASS_NAME__ < Formula
desc "Install locally built DingTalk workspace CLI artifacts for verification"
desc "DingTalk Workspace CLI"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
url "__ARCHIVE_URL__"
sha256 "__ARCHIVE_SHA256__"
@@ -12,6 +12,8 @@ __KEG_ONLY_LINE__
end
def install
require "fileutils"
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
@@ -26,15 +28,44 @@ __KEG_ONLY_LINE__
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
FileUtils.cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
def post_install
require "fileutils"
skill_root = pkgshare/"skills/dws"
entries = Dir["#{skill_root}/*"]
return if entries.empty?
targets = [
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
parent_gate = dest.parent.parent
next if index > 0 && !parent_gate.directory?
FileUtils.rm_rf(dest)
FileUtils.mkdir_p(dest)
FileUtils.cp_r(entries, dest)
end
end
test do
+39 -1747
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@
"README.md"
],
"engines": {
"node": ">=16.7.0"
"node": ">=16"
}
}
-413
View File
@@ -1,413 +0,0 @@
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
// and writes a local diagnostic dump. It is NOT a Schema delivery refresh:
// schema_mcp_metadata.json is retired; production Catalog assembles from
// Contract/ParamDecl/Interface + Cobra only.
//
// Usage:
//
// dws auth login # ensure valid auth
// make fetch-mcp-metadata # writes artifacts/mcp_metadata_diagnostic.json
//
// The tool loads auth from the DWS keychain, iterates static server endpoints
// (internal/syncdata.StaticServers), calls tools/list on each, merges results,
// and writes the requested -output path (refuses the retired pin path).
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"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 = collectedIdentityInterfaceRefs
collectIdentitySpecs = cli.CollectIdentitySpecs
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", "artifacts/mcp_metadata_diagnostic.json", "diagnostic dump path (not a Schema pin)")
if err := flags.Parse(args); err != nil {
return 2
}
if retiredPinnedMCPMetadataPath(*output) {
fmt.Fprintln(stderr, "fetch_mcp_metadata: refusing to write retired Schema pin internal/cli/schema_mcp_metadata.json")
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))
// Collect command identity to build tool_name → interface_ref mapping.
registryMap := loadRegistryInterfaceRefs(stderr)
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
// Load a previous diagnostic dump (if any) 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
}
// collectedIdentityInterfaceRefs collects command identity from the live
// command tree — the replacement for the retired reviewed CommandRegistry —
// and derives the canonical_path → {product_id, rpc_name} mapping used for
// interface_ref injection.
func collectedIdentityInterfaceRefs() (map[string]map[string]string, error) {
root := app.NewSchemaSourceRootCommand()
specs, _, err := collectIdentitySpecs(root)
if err != nil {
return nil, fmt.Errorf("collect command identity: %w", err)
}
out := make(map[string]map[string]string, len(specs))
for _, spec := range specs {
cp := strings.TrimSpace(spec.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, nil
}
// loadRegistryInterfaceRefs builds the canonical_path → interface_ref mapping
// from the collected command identity. 与旧实现同等告警:静默返回空映射会让
// 所有 live tool 被丢弃、产出 stub-only 快照且零提示(P1#1 的故障模式)。
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
refs, err := registrySource()
if err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot collect command identity: %v\n", err)
return map[string]map[string]string{}
}
return refs
}
func retiredPinnedMCPMetadataPath(path string) bool {
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
return cleaned == "internal/cli/schema_mcp_metadata.json" ||
strings.HasSuffix(cleaned, "/internal/cli/schema_mcp_metadata.json")
}
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
// the flat param-name → metadata map used by diagnostic dumps.
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
}
-612
View File
@@ -1,612 +0,0 @@
// 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/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageLoadRegistryInterfaceRefsCollectsIdentity(t *testing.T) {
var stderr bytes.Buffer
refs := loadRegistryInterfaceRefs(&stderr)
if len(refs) == 0 {
t.Fatal("loadRegistryInterfaceRefs() returned no collected commands")
}
got, ok := refs["calendar.list_calendars"]
if !ok {
t.Fatal("calendar.list_calendars missing from collected command identity")
}
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
t.Fatalf("calendar.list_calendars ref = %#v", got)
}
direct, err := collectedIdentityInterfaceRefs()
if err != nil {
t.Fatalf("collectedIdentityInterfaceRefs() error = %v", err)
}
if len(direct) == 0 || direct["calendar.list_calendars"]["rpc_name"] != "list_calendars" {
t.Fatalf("collectedIdentityInterfaceRefs() = %#v", direct["calendar.list_calendars"])
}
prevRegistry := registrySource
prevCollect := collectIdentitySpecs
t.Cleanup(func() {
registrySource = prevRegistry
collectIdentitySpecs = prevCollect
})
registrySource = func() (map[string]map[string]string, error) {
return nil, errors.New("collect boom")
}
stderr.Reset()
if got := loadRegistryInterfaceRefs(&stderr); len(got) != 0 || !strings.Contains(stderr.String(), "cannot collect command identity") {
t.Fatalf("loadRegistryInterfaceRefs error path = %#v stderr=%q", got, stderr.String())
}
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
return nil, cli.IdentityCollectionReport{}, errors.New("walk boom")
}
if _, err := collectedIdentityInterfaceRefs(); err == nil || !strings.Contains(err.Error(), "collect command identity") {
t.Fatalf("collectedIdentityInterfaceRefs wrap error = %v", err)
}
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
return []cli.CommandSpec{
{CanonicalPath: ""},
{CanonicalPath: "nodot"},
{CanonicalPath: "doc.create"},
}, cli.IdentityCollectionReport{}, nil
}
gotRefs, err := collectedIdentityInterfaceRefs()
if err != nil || len(gotRefs) != 1 || gotRefs["doc.create"]["rpc_name"] != "create" {
t.Fatalf("collectedIdentityInterfaceRefs skip = %#v err=%v", gotRefs, err)
}
}
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() (map[string]map[string]string, error) {
return 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"},
}, 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() (map[string]map[string]string, error) {
return map[string]map[string]string{
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "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() (map[string]map[string]string, 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 testRegistryRefs() (map[string]map[string]string, error) {
return map[string]map[string]string{
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
"doc.get_document": {"product_id": "doc", "rpc_name": "get_document"},
}, nil
}
func TestCrossPlatformCoverageRunRefusesRetiredPinnedMCPMetadataPath(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
var stderr bytes.Buffer
code := run([]string{"--output", "internal/cli/schema_mcp_metadata.json"}, &stderr)
if code != 2 {
t.Fatalf("run(retired pin) = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "refusing to write retired Schema pin") {
t.Fatalf("stderr = %q, want retired-pin refusal", stderr.String())
}
if !retiredPinnedMCPMetadataPath("internal/cli/schema_mcp_metadata.json") ||
!retiredPinnedMCPMetadataPath("/tmp/repo/internal/cli/schema_mcp_metadata.json") ||
retiredPinnedMCPMetadataPath("artifacts/mcp_metadata_diagnostic.json") {
t.Fatal("retiredPinnedMCPMetadataPath classification is incorrect")
}
}
func TestRunNoTokenFails(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryRefs)
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{}, testRegistryRefs)
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{}, testRegistryRefs)
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{}, testRegistryRefs)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "" {
t.Fatalf("resolveToken() = %q, want empty", got)
}
}
func TestCrossPlatformCoverageRunWritesSnapshotWithHonestCoverage(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, testRegistryRefs)
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{}, testRegistryRefs)
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 TestCrossPlatformCoverageRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() (map[string]map[string]string, error) { return nil, errors.New("no identity") })
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 collect command identity") {
t.Fatalf("stderr = %q, want identity collection warning", stderr.String())
}
}
func TestRunWriteFailure(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
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{}, testRegistryRefs)
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 TestCrossPlatformCoverageNewToolListerBuildsAuthedClient(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{}, testRegistryRefs)
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)
}
}
-340
View File
@@ -1,340 +0,0 @@
// 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.
// interface-snapshot is an internal CI helper. It is intentionally a separate
// binary so it can be copied into a temporary worktree and compiled against an
// older revision's real Cobra root.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
switch args[0] {
case "generate":
if err := runGenerate(args[1:], stdout, stderr); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
return 0
case "compare":
compatible, err := runCompare(args[1:], stdout, stderr)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if !compatible {
return 1
}
return 0
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
return 2
}
}
func runGenerate(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "-", "snapshot output path, or - for stdout")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("generate accepts no positional arguments")
}
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
if err != nil {
return fmt.Errorf("create isolated home: %w", err)
}
defer os.RemoveAll(home)
environment := map[string]string{
"DWS_CONFIG_DIR": home,
"DWS_LANG": "en",
"HOME": home,
"NO_COLOR": "1",
"USERPROFILE": home,
}
type previousEnv struct {
value string
set bool
}
previous := make(map[string]previousEnv, len(environment))
for key, value := range environment {
oldValue, wasSet := os.LookupEnv(key)
previous[key] = previousEnv{value: oldValue, set: wasSet}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
}
defer func() {
for key, old := range previous {
if old.set {
_ = os.Setenv(key, old.value)
} else {
_ = os.Unsetenv(key)
}
}
}()
previousLang := i18n.Lang()
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
file, err := os.Create(filepath.Clean(*output))
if err != nil {
return fmt.Errorf("create snapshot %q: %w", *output, err)
}
writeErr := interfacesnapshot.Write(file, snapshot)
closeErr := file.Close()
if writeErr != nil {
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
}
if closeErr != nil {
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
}
return nil
}
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
flags.SetOutput(stderr)
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
approvedCommandMigrationsPath := flags.String(
"approved-command-migrations",
"",
"merge-base-owned approved command migration manifest",
)
candidateCommandMigrationsPath := flags.String(
"candidate-command-migrations",
"",
"candidate command migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
if flags.NArg() != 0 {
return false, fmt.Errorf("compare accepts no positional arguments")
}
if *currentPath == "" {
return false, fmt.Errorf("compare requires --current")
}
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-command-migrations and --candidate-command-migrations must be provided together",
)
}
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
return false, fmt.Errorf("read current snapshot: %w", err)
}
references := make(map[string]interfacesnapshot.Snapshot, 2)
if *basePath != "" {
references["main"], err = readSnapshot(*basePath)
if err != nil {
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
}
}
if *stablePath != "" {
references["stable"], err = readSnapshot(*stablePath)
if err != nil {
return false, fmt.Errorf("read stable snapshot: %w", err)
}
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedCommandMigrationsPath != "" {
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
flagCandidate := flagApproved
if *approvedMigrationsPath != "" {
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
if err != nil {
return false, fmt.Errorf("read approved flag migrations: %w", err)
}
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
if err != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", err)
}
}
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved command migrations: %w", readErr)
}
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
current,
references,
flagApproved,
flagCandidate,
commandApproved,
commandCandidate,
)
if err != nil {
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
}
} else if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
return false, fmt.Errorf("write comparison report: %w", err)
}
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.CommandMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadCommandMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
defer file.Close()
return interfacesnapshot.Read(file)
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
}
-700
View File
@@ -1,700 +0,0 @@
// 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 main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
if err != nil {
t.Fatalf("decode generated snapshot: %v", err)
}
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
for _, command := range snapshot.Commands {
commands[command.Path] = command
}
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
if _, ok := commands[path]; !ok {
t.Errorf("actual root snapshot is missing %q", path)
}
}
for _, path := range []string{"dws completion", "dws help"} {
if _, ok := commands[path]; ok {
t.Errorf("framework-noise path %q leaked into snapshot", path)
}
}
create := commands["dws dev app create"]
if !hasFlag(create.LocalFlags, "name", "string") {
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
}
if !hasFlag(create.InheritedFlags, "profile", "string") {
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
stable := commandSnapshot("dws", "dws legacy")
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", current)
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
stablePath := writeSnapshot(t, dir, "stable.json", stable)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", mergeBasePath,
"--stable", stablePath,
}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
var stdout, stderr bytes.Buffer
args := []string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", emptyFlag,
"--candidate-flag-migrations", emptyFlag,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved", invalid, emptyCommand, "read approved command migrations"},
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", test.approved,
"--candidate-command-migrations", test.candidate,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath,
"--approved-command-migrations", emptyCommand,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
}
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing command migration manifest unexpectedly read")
}
if _, err := readCommandMigrationManifest(invalid); err == nil {
t.Fatal("invalid command migration manifest unexpectedly read")
}
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", pending,
"--candidate-command-migrations", consumed,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
commands = append(commands, interfacesnapshot.Command{
Path: path,
Aliases: []string{},
LocalFlags: []interfacesnapshot.Flag{},
InheritedFlags: []interfacesnapshot.Flag{},
})
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: commands,
}
}
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
t.Helper()
path := filepath.Join(dir, name)
file, err := os.Create(path)
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if err := interfacesnapshot.Write(file, snapshot); err != nil {
file.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := file.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func commandMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"kind": "command_move",
"legacy": {
"command": "dws chat message old",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"replacement": {
"command": "dws chat topic new",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.move",
"replacement_tool_id": "chat.move",
"parameters": []
},
"state": "STATE",
"reason": "reviewed command migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
return true
}
}
return false
}
+1 -67
View File
@@ -15,76 +15,10 @@ package main
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
)
var (
appExecute = app.ExecuteWithTelemetry
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
clitrack.New(cfg).Run(execute, exitCode)
}
)
// trackedExitError tells clitrack that the command failed without asking it to
// print the error a second time. The already-rendered message is published via
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
type trackedExitError struct{}
func (trackedExitError) Error() string { return "" }
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
return clitrack.Config{
PID: "wcCRwZ",
App: "dws",
Version: app.RawVersion(),
UID: identity.UserID,
Username: identity.UserName,
NoCommandLine: true,
NoCwd: true,
NoAutomaticDimensions: true,
ExtraFields: func() map[string]string {
fields := map[string]string{"c9": *commandPath}
if identity.CorpID != "" {
fields["c10"] = identity.CorpID
}
if *errorMessage != "" {
fields["c5"] = *errorMessage
}
return fields
},
}
}
func telemetryOptedOut() bool {
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
}
func main() {
optedOut := telemetryOptedOut()
identity := app.TelemetryIdentity{}
if !optedOut {
identity = resolveTelemetryIdentity(os.Args[1:])
}
exitCode := 0
commandPath := "dws"
errorMessage := ""
cfg := trackerConfig(identity, &commandPath, &errorMessage)
if optedOut {
cfg.PID = ""
}
trackRun(
cfg,
func() error {
exitCode, commandPath, errorMessage = appExecute()
if exitCode != 0 {
return trackedExitError{}
}
return nil
},
func(error) int { return exitCode },
)
os.Exit(app.Execute())
}
-220
View File
@@ -1,220 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"sort"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
)
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
for _, wantCode := range []int{0, 1, 3, 5} {
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "")
wantError := ""
if wantCode != 0 {
wantError = "synthetic failure"
}
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
if strings.Join(args, " ") != "sheet read --profile corp-a" {
t.Fatalf("telemetry identity args = %#v", args)
}
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
})
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
called := false
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
called = true
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
}
if cfg.Version != app.RawVersion() {
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
}
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
}
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
}
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
}
err := execute()
if wantCode == 0 && err != nil {
t.Fatalf("successful tracked execute error = %v", err)
}
if wantCode != 0 && (err == nil || err.Error() != "") {
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
}
if gotCode := exitCode(err); gotCode != wantCode {
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
}
fields := cfg.ExtraFields()
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
}
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
}
})
main()
if !called {
t.Fatalf("trackRun was not called for exit code %d", wantCode)
}
})
}
}
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
commandPath := "version"
errorMessage := ""
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
if cfg.UID != "" {
t.Fatalf("empty identity UID = %q", cfg.UID)
}
if cfg.Username != "" {
t.Fatalf("empty identity Username = %q", cfg.Username)
}
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
t.Fatalf("empty organization fields = %#v", fields)
}
}
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
called := false
trackRun(clitrack.Config{}, func() error {
called = true
return nil
}, nil)
if !called {
t.Fatal("default tracker did not execute callback")
}
}
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
testseam.Swap(t, &os.Args, []string{"dws", "version"})
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
return app.TelemetryIdentity{}
})
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
t.Fatalf("opted-out tracker config = %#v", cfg)
}
if err := execute(); err != nil {
t.Fatalf("opted-out execution failed: %v", err)
}
if code := exitCode(nil); code != 0 {
t.Fatalf("opted-out exit code = %d, want 0", code)
}
})
main()
}
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
t.Setenv("SHELL", "/bin/zsh")
t.Setenv("TERM_SESSION_ID", "stable-session")
t.Setenv("TMUX_PANE", "%42")
t.Setenv("LANG", "zh_CN.UTF-8")
t.Setenv("LC_ALL", "zh_CN.UTF-8")
t.Chdir(t.TempDir())
requestBody := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
requestBody <- body
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
commandPath := "sheet read"
errorMessage := ""
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
cfg.Endpoint = server.URL
cfg.FlushTimeout = time.Second
clitrack.New(cfg).Run(func() error { return nil }, nil)
var body []byte
select {
case body = <-requestBody:
case <-time.After(time.Second):
t.Fatal("timed out waiting for telemetry request")
}
var envelope map[string]string
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode telemetry request %q: %v", body, err)
}
decoded, err := url.QueryUnescape(envelope["gokey"])
if err != nil {
t.Fatalf("decode gokey: %v", err)
}
globalFields, err := url.ParseQuery(decoded)
if err != nil {
t.Fatalf("parse global telemetry fields: %v", err)
}
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
if err != nil {
t.Fatalf("parse event telemetry fields: %v", err)
}
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
for key, want := range map[string]string{
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
} {
if got := globalFields.Get(key); got != want {
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
}
}
for key, want := range map[string]string{
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
} {
if got := eventFields.Get(key); got != want {
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
}
}
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
if globalFields.Has(key) {
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
}
}
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
if eventFields.Has(key) {
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
}
}
}
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
t.Helper()
got := make([]string, 0, len(fields))
for key := range fields {
got = append(got, key)
}
sort.Strings(got)
if !slices.Equal(got, want) {
t.Fatalf("telemetry keys = %v, want %v", got, want)
}
}
+6 -63
View File
@@ -1,26 +1,22 @@
# Architecture
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; runtime-assembled Schema (`ResolveSchemaBuild`) serves AI agents.
`dws` is a Go CLI that turns DingTalk MCP metadata into a command-line surface for both humans and AI agents.
## High-Level Flow
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helpers, and versioned plugin descriptors.
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`) and dynamically loads product commands via `internal/plugin`.
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
6. Schema assembly (`ResolveSchemaBuild`) starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, and leaf ContractFinal / ProductDecl into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`. There is no generate-written Catalog delivery step.
7. Production Catalog / `ResolveMeta` consume the lazily assembled registry via `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` / `deliverySchemaCatalog` (声明即 Catalog; lazy `sync.Once`). `ResolveMeta` projects Identity/Safety/Selection from that assembly into an in-process map cache — not a committed `schema_catalog/` or `schema_meta_index.*` fixture. Flag-to-interface property delivery is owned by leaf `ParamDecl.Property` (native annotations). `schema_parameter_mapping_ledger.go` holds reviewed `mapping_exclusions` / `removals` (the empty `schema_parameter_bindings.json` audit table is retired). CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
## Repository Structure
- `cmd`: CLI entrypoint
- `internal/app`: root command wiring, static utility commands, and plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
- `internal/cli`: Schema assembly, `dws schema` query, and catalog contracts
- `internal/generator`: CI/determinism tools (`cmd_schema_catalog` dump) and param-alias generate
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection, identity
@@ -30,69 +26,16 @@
- `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/corecmd`: dispatch-agnostic leaf-command base — flag registration,
alias/env/default value resolution, required and cross-flag constraint
validation, Risk write confirmation, toolArgs assembly, Runtime Schema
projection. Distinct from `internal/cobracmd` (generic tree helpers): it owns
the declarative leaf contract (`corecmd.Spec`) that the LeafSpec framework is
built on and that the Shortcut adapter projects into.
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
- `pkg/mcptypes`: MCP protocol type definitions
- `internal/syncdata`: generated static endpoint and command-routing data synced from the Wukong baseline
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
## Quality Pipeline
Quality enforcement is layered so a pull request receives fast, deterministic
admission feedback without pretending that downstream integration has already
run.
```mermaid
flowchart TB
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
DOCS --> CA["CI"]
STANDARD --> CA
HIGH --> CA
subgraph CA_CHECKS["Nine required contexts"]
L["Lint"]
T["Test"]
C["Coverage"]
P["Policy"]
E["Edition"]
I["Interface Integrity"]
A["AI Behavior"]
S["CLI Smoke"]
M["Mock MCP"]
end
CA --> CA_CHECKS
CA_CHECKS --> MAIN["Protected main"]
MAIN --> MP["Main Integration — 主干集成<br/>Multi-profile E2E"]
MAIN --> PLATFORM["Risk-selected / release native platform validation"]
MP --> RELEASE["Release delivery"]
PLATFORM --> RELEASE
```
All nine named contexts are produced for every tier. Domain-specific helpers
run when their owned surface is affected; otherwise the corresponding context
records an explicit unaffected success. Standard code changes still receive
representative Darwin/Windows compilation. High-risk PRs and protected `main`
run the complete race and native test suites, while platform-sensitive diffs
also receive native changed-code coverage.
Review orchestration is also base-owned: it requests one eligible peer without
executing PR code, re-routes an updated head when needed, and auto-merge
completes only after the latest push has peer approval plus the current
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
classification, context, reviewer, and ruleset contract.
- `envelope/`: pre-built discovery payloads for offline use
+227
View File
@@ -0,0 +1,227 @@
# 审计日志(Audit Log)
DWS 自动记录每次 MCP HTTP 调用的审计事件,用于合规追溯和安全审查。默认启用,无需额外配置。
## 功能特性
- **自动记录**:每次命令执行产生一条 JSONL 审计事件
- **按天轮转**:日志文件按日期分割(`audit-YYYYMMDD.jsonl`),默认留存 90 天
- **防篡改**:L1 哈希链(sha256),每条事件链接前一条的 hash,可验证完整性
- **远端转发**:支持 POST 到外部 SIEM 或审计平台
- **三级脱敏**:转发时可按 none/hashed/minimal 脱敏敏感字段
- **CLI 命令**:内置 `dws audit tail/export/verify` 查看和验证
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DWS_AUDIT` | 启用 | 设 `0`/`false`/`off` 关闭审计 |
| `DWS_AUDIT_DIR` | `~/.dws/audit` | 审计日志目录 |
| `DWS_AUDIT_RETENTION_DAYS` | `90` | 日志留存天数 |
| `DWS_AUDIT_FORWARD_URL` | (空) | 远端转发 URL(POST JSON) |
| `DWS_AUDIT_FORWARD_TOKEN` | (空) | 远端转发 Bearer Token |
| `DWS_AUDIT_FORWARD_REDACT` | `none` | 转发脱敏级别:`none`/`hashed`/`minimal` |
## 审计事件格式
每条事件是一行 JSON,字段如下:
```json
{
"ts": "2026-07-06T10:59:06+08:00",
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_id": "agent-xxx",
"actor": {
"user_id": "525018",
"name": "胡奕舟",
"corp_id": "ding8196cd9a2b2405da24f2f5cc6abecb85",
"corp_name": "钉钉"
},
"product": "calendar",
"command": "list_events",
"endpoint": "https://api.dingtalk.com/v1.0/calendar/users/xxx/calendars/primary/events",
"params_summary": "maxResults=20",
"result": "success",
"error_category": "",
"error_reason": "",
"duration_ms": 234,
"cli_version": "1.0.47",
"os": "darwin",
"arch": "arm64",
"prev_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
**字段说明**:
- `ts`:事件时间戳(RFC3339)
- `execution_id`:本次命令执行的唯一 ID
- `agent_id`:Agent 标识(若有)
- `actor`:执行者信息(从登录态获取)
- `product`:调用的产品(如 `calendar`、`chat`、`contact`)
- `command`:调用的命令(如 `list_events`、`send_message`)
- `endpoint`:实际请求的 API 端点(已脱敏)
- `params_summary`:参数摘要(已脱敏)
- `result`:`success` 或 `error`
- `error_category` / `error_reason`:错误分类和原因(成功时为空)
- `duration_ms`:命令执行耗时(毫秒)
- `prev_hash` / `hash`:哈希链字段,用于防篡改验证
## CLI 命令
### 查看最近日志
```bash
# 查看最近 20 条审计事件(默认)
dws audit tail
# 查看最近 50 条
dws audit tail -n 50
```
输出示例:
```
2026-07-06 10:59:06 calendar list_events user=525018 result=success 234ms
2026-07-06 10:58:42 chat send_message user=525018 result=success 156ms
2026-07-06 10:57:15 contact search user=525018 result=success 89ms
```
### 导出日志
```bash
# 导出最近 7 天的 JSONL
dws audit export --since 2026-06-29 --until 2026-07-06 --format jsonl > audit-7d.jsonl
# 导出为 CSV(方便 Excel 打开)
dws audit export --since 2026-07-01 --format csv > audit-july.csv
```
### 验证哈希链
```bash
# 验证当前最新日志文件的哈希链完整性
dws audit verify
# 验证指定文件
dws audit verify --file ~/.dws/audit/audit-20260706.jsonl
```
输出:
```
✓ 哈希链完整:234 条事件全部校验通过
```
或:
```
✗ 哈希链断裂:第 156 条事件的 hash 不匹配
```
## 哈希链防篡改
每条事件的 `hash` 字段由以下公式计算:
```
hash = sha256(prev_hash + event_json_without_hash_fields)
```
- 首条事件的 `prev_hash` 为空字符串
- 后续事件的 `prev_hash` = 前一条的 `hash`
- 任何对历史事件的篡改都会导致后续所有 hash 失效
**验证流程**:
1. 读取日志文件,逐行解析
2. 对每条事件,移除 `prev_hash` 和 `hash` 字段,重新序列化
3. 用前一条的 hash + 当前事件 JSON 计算新 hash
4. 对比计算结果与文件中记录的 hash
5. 全部匹配 = 完整;某条不匹配 = 被篡改
## 远端转发
设置 `DWS_AUDIT_FORWARD_URL` 后,每条审计事件会异步 POST 到指定端点:
```bash
export DWS_AUDIT_FORWARD_URL=https://siem.example.com/ingest/audit
export DWS_AUDIT_FORWARD_TOKEN=your-bearer-token # 可选
```
**请求格式**:
```http
POST /ingest/audit HTTP/1.1
Host: siem.example.com
Authorization: Bearer your-bearer-token
Content-Type: application/json
{"ts":"2026-07-06T10:59:06+08:00","product":"calendar",...}
```
**超时与重试**:3 秒超时,失败不阻塞命令执行(best-effort),不自动重试。
## 脱敏分级
转发时可通过 `DWS_AUDIT_FORWARD_REDACT` 控制脱敏级别:
| 级别 | 行为 | 适用场景 |
|------|------|----------|
| `none`(默认) | 原样转发,不脱敏 | 内部审计平台 |
| `hashed` | actor.name 哈希化,params_summary 脱敏 | 跨部门共享 |
| `minimal` | 仅保留 ts/product/command/result/duration_ms,移除 actor/endpoint/params | 对外合规报告 |
**示例**:
```bash
# 最小化脱敏(仅保留元数据)
export DWS_AUDIT_FORWARD_REDACT=minimal
export DWS_AUDIT_FORWARD_URL=https://compliance.example.com/audit
```
## 常见问题
### Q: 审计日志占多少磁盘?
典型场景(每天 100 条命令)约 50KB/天,90 天约 4.5MB。日志文件是 JSONL 纯文本,gzip 压缩后约 1/5。
### Q: 关闭审计会影响性能吗?
设置 `DWS_AUDIT=0` 后,审计模块不初始化,零开销。默认启用时,每条事件写入耗时 <1ms(异步磁盘 IO)。
### Q: 哈希链断了怎么办?
可能原因:
1. 手动编辑过日志文件
2. 磁盘损坏
3. 并发写入导致顺序错乱(罕见)
**处理**:
- 备份当前日志
- 用 `dws audit verify` 定位断裂位置
- 从断裂点之后的事件可继续验证(前缀已不可信)
### Q: 如何清理旧日志?
自动清理:`DWS_AUDIT_RETENTION_DAYS=90`(默认),超过 90 天的文件在下次启动时 best-effort 删除。
手动清理:
```bash
# 删除 2026 年 6 月之前的日志
rm ~/.dws/audit/audit-202605*.jsonl
```
## 技术实现
- **核心包**:`internal/audit`
- **集成点**:`internal/app/runner.go` 的 `executeInvocation` 方法(defer 调用 `emitAudit`)
- **身份获取**:从 `auth.LoadTokenData` 读取当前登录用户
- **参数脱敏**:调用 `logging.SanitizeArguments`(与现有日志脱敏逻辑一致)
## 相关文档
- [环境变量参考](./reference.md)
- [架构概览](./architecture.md)
- [自动化与脚本](./automation.md)
+1 -69
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Policy Checks
@@ -62,74 +62,6 @@ make lint
git diff --check
```
## Homebrew Formula Delivery
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
exactly one tracked Formula after the immutable GitHub assets and their
checksums have passed verification. The publisher validates the rendered Ruby,
commits only the configured Formula path, never force-pushes `main`, and retries
from a fresh clone up to three times when `main` advances concurrently. Normal
stable and beta releases do not create a Formula PR or run a permission
canary. The workflow uses the existing repository-scoped
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
Actions App to bypass this repository's rulesets. That identity is the sole
user bypass actor on the two default-branch rulesets. The workflow creates the
nine Code Admission checks for the Formula-only commit only after proving its
sole parent already has all nine successful checks and the committed Formula
exactly matches this release's verified bytes.
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
keep its owner as the designated ruleset bypass actor, and do not reuse
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
path restriction; GitHub rulesets do not infer that restriction from the token.
## Release Governance and Recovery
Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only
repository `Administration: read`. The immutable-releases REST endpoint is an
administration setting and cannot be read by the workflow's built-in
`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag
contract use this same credential so a missing or expired identity is detected
before an irreversible tag is created.
Recovery is restricted to an existing annotated tag whose exact tag object,
commit, sealed metadata, original failed run/attempt, requester identity and
Release state all match; it then reuses the normal release jobs without a
second-person environment approval. A same-run “Re-run failed jobs” is even
lighter: the seal job may adopt an existing tag only when its complete
authority matches that run and its original attempt is not newer than the
current attempt. Do not put publication secrets in temporary branches or
create ad-hoc recovery workflows.
Cloud-sealed releases mirror to OSS only when the repository variable
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
is provisioned; GitHub, npm, and Homebrew delivery can then complete without
running the OSS step. Once enabled, missing credentials, an invalid Bucket, or
an upload failure remains fail-closed. The cloud tag immutably records the
decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume
that sealed value instead of the variable's later state. Deferred releases
cannot use `repair_oss_version`; enabling OSS applies to later release tags
until an audited immutable repair marker is implemented.
If an immutable GitHub Release and npm package were delivered but an enabled
downstream China mirror failed, dispatch the normal `Release` workflow from the
protected default branch with exactly one of `repair_gitee_version` or
`repair_oss_version`. Channel repair accepts a fully successful exact release,
or a failed exact-tag run only when its latest attempt completed the release
contract, build, Apple signature, immutable GitHub publication, and npm
delivery checks for the exact tagged commit. OSS repair additionally requires
the tag's sealed policy to be `enabled`. It then downloads and re-verifies the
immutable assets before invoking only the selected mirror. For a failed
release, an OSS repair requires the OSS step itself to be the recorded failure.
A Gitee repair accepts either a failed Gitee job or a Gitee job that was
skipped behind that OSS failure; the latter is an explicit Gitee backfill and
does not claim that OSS has been repaired. Gitee repair requires `GITEE_TOKEN`,
`GITEE_USER`, and `GITEE_REPO`; OSS repair requires `OSS_ACCESS_KEY_ID`,
`OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` (with optional
`OSS_PREFIX`) as Actions secrets. Missing credentials fail the selected repair
closed.
## Handoff Checklist
Before handoff, include:
-268
View File
@@ -1,268 +0,0 @@
# CI — PR 合入门禁
The pull-request admission layer has exactly nine required external contexts:
| Required context | Contract |
|---|---|
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
| `Edition` | Edition contract tests |
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` |
| `CLI Smoke` | Offline help for every public top-level command |
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
The workflow display name is `CI`. Parallel helper
jobs may implement `Test` and `Coverage`, but they are not ruleset contexts.
Do not require an aggregate alias or a downstream integration check in place of
the nine contracts above.
`AI Behavior` is evaluated by a `pull_request_target` workflow that never
checks out or executes PR code. It writes the exact `AI Behavior` status to the
current PR head. Its Files API read is bracketed by base/head revision checks,
so a synchronize race fails closed. The same workflow supplies a successful
`AI Behavior` check run on protected `main` pushes for release governance.
## Exact CHANGELOG-only fast path
A pull request qualifies only when GitHub reports exactly one changed file,
that file is an in-place modification of `CHANGELOG.md`, and the base and head
both retain it as a regular non-executable `100644` blob. Add, delete, rename,
symlink, executable-mode, and second-file changes do not qualify.
`Lint` classifies the Files API result only after verifying that the API's base
and head equal the event revision both before and after pagination. `Policy`
checks out GitHub's PR merge ref and verifies its parents:
```text
HEAD^1 = pull_request.base.sha
HEAD^2 = pull_request.head.sha
```
It then runs:
```sh
./scripts/policy/check-changelog-pr.sh \
--fast-path "$PR_BASE_SHA" HEAD
```
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
All nine admission contexts are still emitted and must succeed. Expensive
implementation helpers are skipped; the named contexts record that their code
surface is unaffected.
The protected `main` push keeps that fast path only when all of these
fail-closed conditions hold:
- the event is a non-forced update of the existing `refs/heads/main`;
- the event `after` SHA is the exact workflow SHA, and both event SHAs are
complete, non-zero commit IDs;
- GitHub's comparison reports the previous main tip as the unique linear merge
base, with no commits behind it;
- the complete resulting tree diff is exactly one in-place modification of
`CHANGELOG.md`;
- the previous main tip already has successful GitHub Actions checks for all
nine Code Admission contexts.
`Policy` then independently checks out the pushed revision and runs the same
`check-changelog-pr.sh --fast-path` contract from the event's `before` SHA to
its `after` SHA. If identity, ancestry, file scope, tree mode, CHANGELOG
content, or predecessor admission cannot be proved, classification falls back
to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
`Lint` resolves the complete base/head diff before any helper is skipped.
Unknown or truncated input fails closed into the high-risk tier.
| Tier | Selection | Admission work |
|---|---|---|
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
for example) execute their substantive suites when the diff can affect that
contract or when the high-risk tier is selected. Otherwise their stable named
contexts still report a successful, explicit unaffected result. Release-script
tests follow the same impact rule. This preserves the ruleset contract without
charging every developer for unrelated work.
Platform-sensitive changes additionally run native changed-code coverage.
Protected `main` always runs native tests; generic portable changes are held to
the Linux changed-code gate rather than being forced to manufacture
platform-only coverage.
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
an explicit manual dispatch). A failing downstream run remains a real
regression and must be repaired, but it must not be represented by a synthetic
successful PR check.
```mermaid
flowchart TB
PR["Pull request"] --> ADMISSION["CI"]
ADMISSION --> L["Lint"]
ADMISSION --> T["Test"]
ADMISSION --> C["Coverage"]
ADMISSION --> P["Policy"]
ADMISSION --> E["Edition"]
ADMISSION --> I["Interface Integrity"]
ADMISSION --> A["AI Behavior"]
ADMISSION --> S["CLI Smoke"]
ADMISSION --> M["Mock MCP"]
ADMISSION --> MAIN["Protected main"]
MAIN --> NATIVE["Full native platform matrix"]
MAIN --> E2E["Multi-profile E2E"]
MAIN --> RELEASE["Release delivery"]
```
## Review ownership and auto-merge
A base-owned `pull_request_target` workflow routes newly opened, updated,
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
does not check out or execute PR code, excludes both the author and the known
latest pusher, and balances the open requested-review load across the reviewed
maintainer pool. A current-head approval or change request is preserved; after
a new push, stale activity does not suppress a fresh request, and an
outstanding change requester is preferred for continuity.
The branch ruleset keeps one human approval and all nine strict required
contexts, and requires someone other than the latest pusher to approve after
the most recent head update. Repository auto-merge is enabled for ready PRs,
so a PR merges after that approval and the current revision's nine checks are
green. If `main` advances, strict checks rerun before merge. The reviewer
router is orchestration, not a quality context, and must not be added to the
ruleset.
## Running focused gates locally
Run the contracts relevant to the change. Ordinary contributors are not
expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag
与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与
stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过
Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史
副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. The complete candidate profile is produced by disjoint per-shard
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
`-p 1` inside each shard; `verify` proves the shard union equals the
full-suite scope exactly once) and concatenated in the aggregate job before
enforcement. The complete merge-base profile is restored from an exact-key
cache written by the last green `main` push of that same commit (key:
merge-base SHA plus resolved Go version); any miss falls back to recomputing
it in a merge-base worktree. The trusted `main` producer and PR consumer use
the same dedicated cache profile path because GitHub includes that path in the
cache version; the runtime-facing candidate and baseline filenames remain
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
keys, because a neighbouring commit's profile would compare the candidate
against the wrong baseline. Supporting and (when
platform-selected) native profiles are generated before the aggregate
`Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## Required GitHub repository settings
The `main` quality ruleset must enable strict required-status-check policy
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
`main` advances. It must require these exact contexts and no legacy aliases:
- `Lint`
- `Test`
- `Coverage`
- `Policy`
- `Edition`
- `Interface Integrity`
- `AI Behavior`
- `CLI Smoke`
- `Mock MCP`
Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
alias. Update ruleset contexts only after the new names have appeared on the
protected branch, so a rename cannot silently remove enforcement or leave an
unproducible required context.
The branch ruleset also requires one approval after the latest push. Enable
repository auto-merge and automatic head-branch deletion; keep the base-owned
reviewer router outside the required-context list.
-223
View File
@@ -1,223 +0,0 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
## 跨命令迁移原语
`approved-command-migrations-v1.json` 只接受两种 `kind`:
| kind | CLI after 状态 | Schema 允许的精确投影 |
|---|---|---|
| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 |
| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 |
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的
`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史
Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受
optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool
的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好
一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有
`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于
常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入
`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份
同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加
bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后
删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则
`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与
`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用
独立 PR 合入,不能和产品消费混在一起。
replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值;
历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
-209
View File
@@ -1,209 +0,0 @@
# command 领域模型
本文档描述 `internal/corecmd` 包的领域模型——类型、概念及其关系。
## 核心模型图
```
┌─────────────────────────────────────────────────────────────────────┐
│ corecmd.Spec │
│ (一个叶子命令的完整契约) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─── CLI 表面 ───┐ ┌─── 参数声明 ───────────────────────────┐ │
│ │ Use │ │ FlagSpec[] │ │
│ │ Short │ │ ├─ Name / Kind / Default │ │
│ │ Long │ │ ├─ Required / MarkRequired │ │
│ │ Example │ │ ├─ Aliases[] / EnvVar (回退链) │ │
│ └────────────────┘ │ ├─ Bind / Transform / OmitEmpty │ │
│ │ └─ Enum / Format / SchemaDescription │ │
│ │ │ │
│ │ Constraint[] │ │
│ │ ├─ at_least_one │ │
│ │ ├─ exactly_one │ │
│ │ └─ mutually_exclusive │ │
│ │ │ │
│ │ ConstParams map[string]any │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │
│ │ Safety contract.SafetySpec │ │
│ │ ├─ Effect (read / write / destructive) │ │
│ │ ├─ Risk (low / medium / high) │ │
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
│ │ └─ Idempotency (idempotent / retryable / …) │ │
│ │ │ │
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── Contract 声明 (Agent 可见的元数据) ────────────────────────┐ │
│ │ ContractDecl │ │
│ │ ├─ Title / Description │ │
│ │ ├─ contract.DryRunSpec {PreviewKind, RemoteReads} │ │
│ │ ├─ contract.InterfaceSpec {Mode, Availability, Reason, Ref}│ │
│ │ ├─ contract.SelectionSpec {AgentSummary, UseWhen, AvoidWhen│ │
│ │ │ Prerequisites, Tips, Examples} │ │
│ │ ├─ contract.ToolIdentitySpec {ProductID, CanonicalPath, …} │ │
│ │ └─ Positionals[] {Name, Type, Required, Variadic} │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── 执行体 (恰好一个) ─────────────────────────────────────────┐ │
│ │ Invoke(Ctx, toolArgs) ← #830 过渡:单步派发(目标 mcpbind)│ │
│ │ Orchestrate(Ctx) ← #830 过渡:多步编排(目标 Handler)│ │
│ │ RunE(cmd, args) ← 逃生舱:完全自定义 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── 钩子 ─────────────────────────────────────────────────────┐ │
│ │ Validate(cmd, args) ← 条件式业务校验(约束表达不了的) │ │
│ │ PostMount(cmd) ← 挂载收尾(设置 Args 等 cobra 属性) │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 构建与执行流
```
corecmd.Spec ──── corecmd.New() ────▶ cobra.Command ──── 用户执行 ────▶ Ctx
│ │ │
构建时检查: 注册产物: 执行上下文:
• validateDispatchDecl • Flags + Aliases • Str(name)
• validateSafetySpec • Annotations (Schema) • Int(name)
• validateContractDecl • Long (约束 help) • Bool(name)
• RegisterFlags • RunE (管线) • StrSlice(name)
• ValidateConstraintDecls • Changed(name)
• embedContractIntoSchema • DryRun() / Yes()
• AnnotateConstraints
• PostMount
```
## 领域概念
| 概念 | 类型 | 职责 |
|------|------|------|
| **corecmd.Spec** | struct | 一个命令的完整契约(声明 + 执行) |
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
| **Constraint** | struct | 参数间的关系约束 |
| **Safety** | contract.SafetySpec | 运行时与 Schema 共用的安全契约 |
| **ContractDecl** | struct | Agent 可见的完整工具规格声明 |
| **contract.SelectionSpec** | struct | Agent 选择该工具的语义指引 |
| **contract.InterfaceSpec** | struct | 工具的接口模式与可用性 |
| **contract.DryRunSpec** | struct | dry-run 能力声明 |
| **contract.ToolIdentitySpec** | struct | 工具在注册表中的身份标识 |
| **contract.RuntimeSchemaPositional** | struct | 有序位置参数声明 |
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
| **New** | func | 统一构建器(`corecmd.Spec` → `*cobra.Command`) |
## SafetySpec
`corecmd.Spec.Safety` 使用 `corecmd/contract.SafetySpec`(无 cli 类型别名),没有 command 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
```go
Safety: contract.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
}
```
- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。
- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。
- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。
- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。
## FlagSpec 有效值回退链
框架统一的 flag 值解析顺序:
```
显式主 flag (Changed)
│ 空?
▼
隐藏别名 (Changed, 按声明序)
│ 空?
▼
环境变量 (EnvVar)
│ 空?
▼
注册默认值 (Default)
│ 空?
▼
ArgDefault (兜底)
```
各 Kind 的特殊行为:
| Kind | 入参条件 | 回退链 |
|------|----------|--------|
| KindString | 有效值非空(或 !OmitEmpty) | 完整参与 |
| KindInt | 值 ≠ 0(putInt 语义) | 完整参与 |
| KindBool | Changed 时入参(显式 false 也下发) | 不参与别名/env 回退 |
| KindStringSlice | 存在非空元素 | 仅 Changed 的主 flag/alias |
## Constraint 约束
声明式跨 flag 关系,构建时校验合法性,运行时统一执行:
| Kind | 语义 | 错误文案示例 |
|------|------|-------------|
| `at_least_one` | 至少提供一个 | "请至少指定 --a、--b 之一" |
| `exactly_one` | 恰好提供一个 | "请指定 --a、--b 之一" / "只能指定其一" |
| `mutually_exclusive` | 最多提供一个 | "参数 --a、--b 互斥,只能指定其一" |
"是否提供"的判定复用有效值回退链(显式主 flag → 别名 → env),注册默认值不算作已提供。
## ContractDecl 子结构
### contract.SelectionSpec(Agent 选择指引)
```go
contract.SelectionSpec{
AgentSummary: "一句话描述工具做什么",
UseWhen: []string{"在什么场景下应该选择这个工具"},
AvoidWhen: []string{"什么场景不应该用,应该用什么替代"},
Prerequisites: []string{"使用前提条件"},
Tips: []string{"使用技巧"},
Examples: []string{"dws dev app create --name Bot --dry-run"},
}
```
### contract.InterfaceSpec(接口模式)
```go
contract.InterfaceSpec{
Mode: "composite", // local / mcp / composite
Availability: "available", // available / unavailable
Reason: "...", // composite/unavailable 时的原因
Ref: &contract.InterfaceRefSpec{ // mcp 时的 ref
ProductID: "...",
RPCName: "...",
},
}
```
### contract.DryRunSpec(dry-run 能力)
```go
contract.DryRunSpec{
PreviewKind: "invocation", // invocation / request / plan
RemoteReads: false, // dry-run 时是否发起远端读
}
```
## 执行体三选一
| 执行体 | 适用场景 | 框架做了什么 |
|--------|----------|-------------|
| **Invoke** | #830 过渡单步派发(生产仍用;目标 mcpbind) | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
| **Orchestrate** | #830 过渡多步编排(生产仍用;目标 Handler) | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 |
| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 |
## 设计不变量
1. **一个 corecmd.Spec = 一个叶子命令的全部事实**
2. **声明面绝不调用后端**——command 是 dispatch-agnostic
3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明
4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段
5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema
6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件
-286
View File
@@ -1,286 +0,0 @@
# 命令框架架构
本文档描述 `internal/corecmd` 统一命令框架的当前架构,面向框架使用者和维护者。
## 概览
```
用户输入 → cobra 命令树 → corecmd.New() → 运行时管线 → 后端派发
```
命令框架将 CLI 命令的**声明**与**执行**分离:
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Contract 元数据)
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。
## 核心类型
### corecmd.Spec
统一的类型化命令规格,是框架的核心数据结构:
```go
type Spec struct {
// 声明面
Use string
Short string
Long string
Example string
Flags []FlagSpec
Constraints []Constraint
Safety contract.SafetySpec // 运行时与 Schema 的单一安全来源
ConfirmFirst bool // 确认门先于参数校验
ConstParams map[string]any
Contract ContractDecl // 叶子 Contract 声明(非 Catalog Schema)
// 执行面(恰好一个;Invoke/Orchestrate 为 #830 过渡派发 API,目标 mcpbind+Handler)
Invoke func(c *Ctx, toolArgs map[string]any) error // 过渡:单步
Orchestrate func(c *Ctx) error // 过渡:多步
RunE func(cmd *cobra.Command, args []string) error // 逃生舱
// 钩子
Validate func(cmd *cobra.Command, args []string) error
PostMount func(cmd *cobra.Command)
}
```
### SafetySpec(单一安全来源)
`Spec.Safety` 使用 `corecmd/contract.SafetySpec`:
| 字段 | 职责 |
|------|------|
| `Effect` | 操作影响:read / write / destructive |
| `Risk` | 风险等级:low / medium / high |
| `Confirmation` | 是否需要用户确认:not_required / user_required |
| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown |
四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整:
```go
Safety: contract.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
},
```
完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。
### FlagSpec
声明一个 flag 的注册方式、有效值回退链、到 toolArgs 的绑定:
```go
type FlagSpec struct {
Name string // flag 名(kebab-case)
Usage string // --help 文案
Kind FlagKind // String / Int / Bool / StringSlice
Default string // 注册默认值
Required bool // 框架校验非空
Aliases []string // 隐藏别名
EnvVar string // 环境变量回退
Bind string // toolArgs 键名(空则用 Name)
Transform func(string) (any, error) // 值转换
// ...更多字段见源码
}
```
### Constraint
跨 flag 关系约束:
```go
type Constraint struct {
Kind ConstraintKind // at_least_one / exactly_one / mutually_exclusive
Flags []string
}
```
## 有效值回退链
flag 解析按以下顺序取值(先命中先生效):
```
显式主 flag (Changed) → 隐藏别名 (Changed) → 环境变量 → 注册默认值
│
ArgDefault ←──┘ (兜底)
```
- KindBool:仅 Changed 时生效,不参与回退链
- KindStringSlice:仅主 flag / alias Changed 时生效,元素恒 TrimSpace
- KindInt:非零才入 toolArgs(putInt 语义)
## 构建时流程
`corecmd.New(spec)` 执行以下构建时检查(失败则 panic):
1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE)
2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整
3. **validateContractDecl** — Contract 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface)
4. **RegisterFlags** — flag + alias 注册到 cobra
5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
7. **AnnotateConstraints** — 约束渲染到 --help
8. **PostMount** — 调用方的挂载收尾钩子
## 运行时流程
生成的 `RunE` 按以下顺序执行:
```
[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验
│
▼
ValidateRequired ← 有效值回退链校验
│
▼
ValidateConstraints ← 互斥/至少一个/恰好一个
│
▼
Validate hook ← 条件式业务校验(可选)
│
▼
BuildArgs ← flag → toolArgs 装配
│
▼
ConstParams 合并
│
▼
[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认
│
▼
Invoke(ctx, toolArgs) ← #830 过渡:单步派发
或 Orchestrate(ctx) ← #830 过渡:多步编排
```
## 消费方式
### LeafSpec(MCP 直连叶子命令)
```go
func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建开放平台企业内部应用",
Tool: devAppCreateTool,
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称 (必填)", Bind: "name",
Trim: true, Required: true, RequiredHint: "--name 为必填"},
},
Contract: ContractDecl{
Description: "创建开放平台企业内部应用",
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
Selection: contract.SelectionSpec{
AgentSummary: "创建钉钉开放平台应用",
UseWhen: []string{"需要新建企业内部应用"},
AvoidWhen: []string{"应用已存在时用 update"},
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
},
},
Call: devAppCall(runner),
})
}
```
`NewLeafCommand` 经 `FromLeafSpec()` 归一为 `corecmd.Spec`,再交 `corecmd.New()` 构建。这是**完全托管模式**:声明 + 执行都归 command。
### 声明元数据模式(既有命令补 Contract)
执行体必须冻结时,用同一套 `LeafSpec` 词汇只声明元数据,写在命令字面量旁:
```go
baseListCmd := &cobra.Command{
Use: "list", Short: "获取 AI 表格列表",
RunE: func(cmd *cobra.Command, args []string) error { /* 原执行体不动 */ },
}
DeclareLeafMetadata(baseListCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: ContractDecl{
Description: "列出最近访问的 AI 表格 Base。",
Interface: aitableMCPInterface("list_bases"),
Selection: contract.SelectionSpec{
AgentSummary: "列出最近访问的 AI 表格 Base。",
UseWhen: []string{"只需浏览最近打开过的 Base 时"},
AvoidWhen: []string{"按名称查找优先 base search"},
Examples: []string{"dws aitable base list"},
},
},
})
```
`DeclareLeafMetadata` 调用 `corecmd.AttachContract` 挂 Safety+Contract;不注册 flag、不接管参数投影。可选 `Validate` 与 `ConfirmSafety` 同挂在 **RunE 包装器**内(不是 PreRunE)。当 `Safety.Confirmation=user_required` 时,用**同一份** SafetySpec 包一层 `ConfirmSafety`,保证执行门禁与 Catalog 同源;无 Validate 时确认推迟到 gated `CallTool`,成功返回却未确认则 fail-closed。迁移态入口;新命令仍应走 `NewLeafCommand`。
### 三档路径(当前可接受)
| 档 | 入口 | 说明 |
|---|---|---|
| **Tier1** | `corecmd.New` / `NewLeafCommand` | 完全托管:声明 + 执行都归框架 |
| **Tier2** | `DeclareLeafMetadata` | helpers 迁移态;**Shortcut 也可采用,可接受** |
| **Tier3** | 裸 Cobra | 应逐步收;新增裸叶需补声明或精确排除 |
长期展望(非当前硬要求):更多 Shortcut 可收敛到 mcpbind / 减少仅为参数装配的 `Execute`。**不要**把「Shortcut 必须去掉 Execute / 必须 mcpbind」当作当前门禁;也不要否定 Shortcut + `DeclareLeafMetadata`。
### Shortcut(智能快捷方式,已接入 live mount)
```go
func mount(s Shortcut) *cobra.Command {
return corecmd.New(FromShortcut(s))
}
spec := FromShortcut(Shortcut{
Service: "chat",
Command: "+demo",
Risk: RiskHighWrite,
Flags: []Flag{...},
Execute: func(rt *RuntimeContext) error { ... },
})
```
Shortcut 当前仍保留自身的 `Risk`,adapter 只在边界将它展开成完整
`contract.SafetySpec`;command/Leaf 不再保留该枚举。Shortcut 的 Cobra
type/default/usage provenance 保持不变,command 统一补充 Required、Enum 和关系约束投影。
需要补 Agent Schema 且执行体暂不迁入时,Shortcut 也可走 Tier2
`DeclareLeafMetadata`(与 helpers 同一路径)。
## 文件结构
| 文件 / 包 | 职责 |
|------|------|
| `internal/corecmd/corecmd.go` | 核心类型 + `New` 构建器 + 运行时管线 |
| `internal/corecmd/contract_decl.go` | ContractDecl 载荷类型 + 声明完整性守卫 |
| `internal/corecmd/contract/` | 契约 DTO(`SafetySpec` / `ParamDecl` / `ProductDecl` / `ContractFinalPayload`);**无** Cobra-keyed Final store |
| `internal/corecmd/runtimeannotate/` | `AnnotateRuntime*` 写注解(框架侧;`cli` 薄 re-export) |
| `internal/corecmd/contractfinal/` | ContractFinal Cobra store + `RegisterRuntimeContractFinal`(框架侧;`cli` 薄 re-export) |
| `internal/cli/homology/` | flag/help/schema 同源门禁(`HOM-*`) |
| `internal/helpers/leaf.go` | LeafSpec 门面:`NewLeafCommand`(完全托管)+ `DeclareLeafMetadata`(声明元数据) |
| `internal/shortcut/adapter.go` | FromShortcut 完整映射与 Risk 兼容边界 |
| `internal/shortcut/runner.go` | RuntimeContext;live mount 委托 `corecmd.New(FromShortcut(s))` |
## Schema 投影
声明即 review:代码中的 Contract 声明经过 code review 后直接投影为:
- **Agent Runtime Schema**(`dws.schema.*` Cobra annotations;经 `runtimeannotate` / ContractFinal 嵌入)
- **运行时组装的 SchemaRegistry / Catalog ToolSpec wire**(`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`;`dws schema` / `--all` / 完整 leaf 载荷)
- **CommandMeta 投影**(装配 Once 同步缓存 `map[cli_path]CommandMeta`;`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety 稳态 O(1) 读缓存,与 SchemaRegistry 同源)
- **Dry-run Capabilities**(声明自动索引为 reviewed 能力)
生产权威是 leaf `ContractFinal` / `ProductDecl`(经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 装配进 Catalog);`InstallBuildTimeAgentMetadataJSON` 仅用于 `cmd_schema_catalog` 的 CI/local dump inject,不是生产交付路径。`schema_agent_metadata/` 与 `schema_hints/` 已退役。不再需要外部 hint 文件维护 selection/metadata/dry-run 信息。Catalog/meta-index 路径不得提交。
## 设计原则
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Contract 是声明;Invoke/Validate/PostMount 是执行
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
-236
View File
@@ -1,236 +0,0 @@
# 命令框架对比:DWS command vs lark-cli vs GWS
本文档对比 DWS(钉钉工作区 CLI)、lark-cli(飞书 CLI)和 GWS(Google Workspace CLI / gcloud)三套命令框架的设计差异。
## 总览对比
| 维度 | DWS (command) | lark-cli | GWS (gcloud) |
|------|---------------|----------|--------------|
| 语言 | Go | Go | Python (gcloud) / Go (部分) |
| CLI 框架 | cobra | cobra | argparse + calliope |
| 调用底座 | MCP JSON-RPC | Lark REST SDK (`CallAPITyped`) | Google API Client |
| 命令层次 | 2 层:LeafSpec + Shortcut | 3 层:Shortcuts + API Commands + Raw API | 2 层:surface commands + raw |
| Schema 来源 | 代码声明投影 | 代码声明 + 运行时 introspection | API Discovery 文档自动生成 |
| Agent 适配 | 内建 (dws.schema.*) | 内建 (--print-schema) | 外挂 (MCP adapter) |
## 架构对比
### DWS command
```
corecmd.Spec (声明) → corecmd.New() → cobra.Command
│
├── contract.SafetySpec (运行时 + Schema 单一安全来源)
├── FlagSpec[] (参数 + 回退链 + 绑定)
├── Constraint[] (互斥/至少一个)
├── ContractDecl (Agent Selection/DryRun/Interface)
│
└── Invoke / Orchestrate / RunE (执行)
```
**核心特点**:
- 声明与执行严格分离
- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema
- 有效值回退链:flag → alias → env → default
- 框架统一校验、装配、确认、投影
- Schema 从代码声明直接投影,无外部 hint 文件
### lark-cli
```
Shortcut (声明) → runner.Mount() → cobra.Command
│
├── Risk string (确认行为)
├── Scopes / ConditionalScopes (OAuth 权限)
├── Flag[] (参数 + Enum + Input sources)
├── AuthTypes (user/bot)
│
├── DryRun hook → DryRunAPI
├── Validate hook
└── Execute hook → RuntimeContext → CallAPITyped
```
**核心特点**:
- Execute 内直接调 REST API (`CallAPITyped`)
- DryRun 是独立 hook(返回结构化 API 计划)
- 内建 OAuth scope 声明与预检
- `--print-schema --flag-name` 运行时 introspection
- 无 Schema 投影层,Agent 通过 introspection 动态发现
### GWS (gcloud 风格)
```
API Discovery → 代码生成 → surface command
│
├── arguments (从 JSON Schema 自动生成)
├── request/response 映射
└── 自定义 action hook (少量)
```
**核心特点**:
- Schema-first:从 API Discovery 文档自动生成命令
- 参数直接映射 API 字段(flat schema)
- 人工 surface command 是 thin wrapper
- Agent 适配通过 MCP 外部 adapter
## 核心设计差异
### 1. 声明粒度
| 能力 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 |
| 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group |
| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 |
| Schema 投影 (Agent metadata) | ✅ ContractDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 |
| 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 |
| ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 |
| 确认门顺序可配 (ConfirmFirst) | ✅ | ❌ 固定顺序 | ❌ 无确认机制 |
### 2. 执行模型
| 维度 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 参数装配 | 框架自动 (BuildArgs) | 手写 (`runtime.Str()/Bool()`) | 自动映射 |
| 派发方式 | Invoke(ctx, toolArgs) | Execute(ctx, runtime) | 自动调用 |
| 多步编排 | Orchestrate(ctx) | Execute 内链式 CallAPITyped | 不支持 |
| DryRun | 框架统一 (--dry-run flag) | 独立 DryRun hook 返回 API 计划 | 部分命令支持 |
| 错误分类 | apperrors 类型化 | errs.Problem 类型化 | HTTP status 映射 |
### 3. Agent 适配
| 维度 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery |
| 选择指引 | contract.SelectionSpec (UseWhen/AvoidWhen) | Description + Tips | 无 |
| 安全声明 | contract.SafetySpec 直接声明 | Risk string | 无 |
| dry-run 能力声明 | contract.DryRunSpec (reviewed) | DryRun hook 存在性 | 无 |
| 接口模式 | contract.InterfaceSpec (local/mcp/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) |
### 4. Schema 生命周期
```
DWS: 代码声明 → code review → cobra annotation → catalog/metadata JSON
(单一数据源,构建时验证完整性)
lark-cli: 代码声明 → 运行时 introspection → Agent 动态发现
(无离线 catalog,Agent 必须执行命令才能发现)
GWS: API Discovery JSON → 代码生成 → surface command
(Schema-first,但命令行体验受限于 API 形状)
```
## 设计哲学对比
### DWS command 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 |
| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 |
| 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 |
| Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection |
| 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 |
| ConfirmFirst | 精确建模遗留语义 | lark-cli 确认始终在 Execute 内 |
### lark-cli 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| 直连 REST API | 精确控制请求/响应,可处理分页/重试 | DWS 通过 MCP 间接调用 |
| DryRun 返回 API 计划 | Agent 可预览将要发出的真实 HTTP 请求 | DWS dry-run 只展示参数 |
| OAuth scope 声明 | 框架预检权限,失败提前 | DWS 依赖 MCP 层鉴权 |
| `--print-schema` introspection | 运行时发现,无需维护离线 catalog | DWS 需要 re-generate |
| Input sources (file/@path/stdin) | 丰富的输入方式声明 | DWS 无此抽象 |
| PrintFlagSchema | 单 flag 级别的 JSON Schema 暴露 | DWS 只在 catalog 级别 |
### GWS (gcloud) 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| API Discovery 驱动 | 一份 Schema 生成所有:SDK/CLI/文档 | DWS/lark 手写 |
| Flat parameter 映射 | API 字段 = CLI flag,零转换 | DWS 需要 Bind 映射 |
| 无 shortcut 层 | API 粒度即用户粒度 | DWS/lark 有精选层 |
## 代码量对比
| 框架 | 核心框架代码 | 单命令声明开销 | 备注 |
|------|-------------|---------------|------|
| DWS command | ~1400 行 (command.go + contract_decl.go) | ~20-30 行 (纯声明) | 框架重、单命令轻 |
| lark-cli | ~800 行 (runner.go + types.go + common.go) | ~50-150 行 (声明 + Execute 逻辑) | 框架轻、单命令重 |
| GWS gcloud | ~5000+ 行 (calliope 框架) | ~10 行 (多数自动生成) | 框架最重、单命令最轻 |
## DWS 命令声明示例 vs lark-cli
### DWS (command / LeafSpec)
```go
NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建应用",
Tool: "create_dev_app",
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称", Bind: "name",
Trim: true, Required: true, RequiredHint: "--name 为必填"},
},
Contract: ContractDecl{
Description: "创建开放平台企业内部应用",
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
Selection: contract.SelectionSpec{
AgentSummary: "创建钉钉开放平台应用",
UseWhen: []string{"需要新建企业内部应用"},
AvoidWhen: []string{"应用已存在时用 update"},
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
},
},
Call: devAppCall(runner),
})
```
### lark-cli (Shortcut)
```go
var CalendarCreate = common.Shortcut{
Service: "calendar",
Command: "+create",
Description: "Create a new calendar event",
Risk: "write",
Scopes: []string{"calendar:calendar"},
Flags: []common.Flag{
{Name: "summary", Desc: "Event title", Required: true},
{Name: "start", Desc: "Start time (RFC3339)", Required: true},
{Name: "end", Desc: "End time (RFC3339)", Required: true},
{Name: "attendees", Type: "string_slice", Desc: "Attendee emails"},
},
DryRun: func(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
return &common.DryRunAPI{
Method: "POST",
Path: "/open-apis/calendar/v4/calendars/{id}/events",
Body: buildEventBody(rt),
}
},
Execute: func(ctx context.Context, rt *common.RuntimeContext) error {
body := buildEventBody(rt)
data, err := rt.CallAPITyped("POST",
"/open-apis/calendar/v4/calendars/{id}/events", nil, body)
if err != nil { return err }
return rt.Output(data)
},
}
```
## 适用场景总结
| 场景 | 最适合 | 原因 |
|------|--------|------|
| MCP 后端 + Agent Schema 投影 | **DWS command** | 内建 Schema 声明、SafetySpec 契约、离线 catalog |
| REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 |
| API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 |
| 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate |
| 遗留系统迁移 (保持行为等价) | **DWS command** | ConfirmFirst + 回退链 + catalog 漂移门禁 |
+7 -13
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -147,8 +147,8 @@ _Group chats, conversations, messages, and robot/webhook integrations._
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
| `dws chat message list` | Pull the recent message history of a specific conversation, including quoted-message context for merged forwards and images. | When the agent needs to read what has recently been said in a conversation and retain the context of replies. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range, surfacing any search-entitlement guidance. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
@@ -164,19 +164,16 @@ _Group chats, conversations, messages, and robot/webhook integrations._
## `dws contact` — Contact Directory
_Users, departments, directory lookups, and enterprise onboarding._
_Users, departments, and directory lookups._
**9 commands**
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws contact account create` | Create a dedicated login account in the current enterprise. | When the user explicitly asks for an enterprise account or login account, rather than a new enterprise organization. |
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
| `dws contact org create` | Create a new DingTalk enterprise organization. | When the user explicitly asks to create or initialize an enterprise and provides its name and creator display name. |
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
| `dws contact user invite` | Invite one employee by mobile number into the current enterprise. | When the user explicitly asks to add an employee and has supplied the employee name and mobile number. |
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
@@ -277,17 +274,14 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
_OA approval workflows: list, approve, reject, revoke, records._
**12 commands**
**9 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
-203
View File
@@ -1,203 +0,0 @@
# 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 交流群二维码】
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
-312
View File
@@ -1,312 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
<title>Drive Shortcut 能力全景|业务评审版</title>
<style>
:root {
color-scheme: light;
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
a { color: inherit; text-decoration: none; }
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
.nav .wrap { display: flex; overflow-x: auto; }
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
.nav a:hover { color: var(--forest); background: #eaf3ee; }
main { padding: 38px 0 74px; }
section { margin-top: 50px; scroll-margin-top: 72px; }
section:first-child { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
h3 { margin: 0 0 7px; font-size: 18px; }
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
.callout strong { color: #13513d; }
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
.callout.warn strong { color: #784b00; }
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.score { padding: 19px; }
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
.domain-grid { grid-template-columns: repeat(4, 1fr); }
.domain-card { position: relative; padding: 21px; overflow: hidden; }
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
.domain-card small { color: var(--green); font-weight: 800; }
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
.compare-column:last-child { border-right: 0; }
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
.compare-column.covered { border-top: 5px solid var(--green); }
.compare-column.partial { border-top: 5px solid #c78b22; }
.compare-column.gap { border-top: 5px solid var(--red); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbf8; }
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
.v-covered, .badge.read { color: #116045; background: var(--mint); }
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
.truth-step strong, .truth-step span { display: block; }
.truth-step span { color: var(--muted); font-size: 12px; }
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-row:hover { background: #f8fbf8; }
.command code { font-size: 12px; }
.row-main p { margin: 0; font-size: 13px; }
.row-main small { color: var(--muted); }
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.hidden-row { display: none; }
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
.evidence-card { padding: 19px; }
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
.event { position: relative; padding: 0 0 17px 22px; }
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
.event b { display: block; }
.event span { color: var(--muted); font-size: 12px; }
.review-grid { grid-template-columns: repeat(3, 1fr); }
.review-card { position: relative; padding: 20px; }
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
</style>
</head>
<body>
<header class="hero">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">Business Review · Drive</p>
<h1>Drive Shortcut<br>能力全景</h1>
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
<div class="meta-row">
<span class="meta-pill good">真实账号 E2E 已执行</span>
<span class="meta-pill">28 个公开入口</span>
<span class="meta-pill">统一 Result / Pagination</span>
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
</div>
</div>
<div class="hero-stats" aria-label="关键统计">
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
</div>
</div>
</header>
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
<main class="wrap">
<section id="overview">
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
<div class="score-grid">
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
</div>
</section>
<section>
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
<div class="domain-grid">
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
</div>
</section>
<section id="compare">
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
<div class="compare">
<div class="compare-top">
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
</div>
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
</tbody></table></div>
</div>
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
</section>
<section id="ahead">
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
<div class="domain-grid">
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
</div>
</section>
<section id="truth">
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
<div class="truth-grid">
<article class="truth-card"><h3>四层成功证据</h3>
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
</article>
<article class="truth-card"><h3>明确失败的情况</h3>
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
</article>
</div>
</section>
<section id="catalog">
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
<div class="catalog">
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
</div>
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
</section>
<section id="e2e">
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
<div class="evidence-grid">
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
</div>
<div class="timeline">
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
</div>
</section>
<section id="review">
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
<div class="review-grid">
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
</div>
</section>
</main>
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
<script>
const q = document.querySelector('#q');
const domain = document.querySelector('#domain');
const risk = document.querySelector('#risk');
const rows = [...document.querySelectorAll('[data-tool]')];
const count = document.querySelector('#result-count');
function filterTools() {
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
let visible = 0;
rows.forEach((row) => {
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
row.classList.toggle('hidden-row', !show);
if (show) visible += 1;
});
count.textContent = `显示 ${visible} / ${rows.length}`;
}
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
</script>
</body>
</html>
-82
View File
@@ -1,82 +0,0 @@
# Drive Shortcut 对齐与超越 Lark CLI
## 目标与判定口径
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
## Lark 38 项映射
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|---|---|---|
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
| `+add-reply` | `doc +comment-reply` | 对齐。 |
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
## DWS 超出 Lark Drive 的可挖掘能力
- `+list`:严格目录分页,而不是把缺字段当空目录。
- `+recent`:最近访问/编辑与创建人筛选。
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
- `+rename`:写后读回验证。
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
## 端到端门禁
每个公开 Drive shortcut 必须至少覆盖:
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
- 写入的 ID/终态证据与读回不一致;
- 下载的本地路径边界、no-clobber、真实字节数;
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
-219
View File
@@ -1,219 +0,0 @@
# Event consume — AI subprocess contract
Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the six **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
## Baseline (already present, no work)
- `--max-events N` — stop after N events (exit 0).
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
aliased to `--timeout`: the global `--timeout` is the HTTP request
timeout (int seconds) and would collide (different type and meaning).
- Bus idle-shutdown fires only with **zero** consumers, so a connected
consumer is never idle-killed.
- SIGINT/SIGTERM already cancel the run context and return cleanly.
## Improvements
### 1. Ready marker (standardized)
On connect, emit a fixed stderr line **before** any stdout event:
```
[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>
```
Parents block on stderr until this line, then read stdout. Suppressed
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
omits `event_key`).
**Verification**
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
- T1b: that line appears before the first stdout event (ordering).
- T1c: with `--quiet`, the line is absent.
### 2. stdin EOF = graceful exit
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
subprocess callers). To stay resident, feed a never-EOF stdin
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
**Verification**
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
line `reason: signal` (stdin-eof classified as signal).
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
5s, connection intact.
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
via the cleanup path.
### 3. Exit reason contract + exit codes
On exit, final stderr line:
```
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
```
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
or runtime failure (permissions, network, params) = non-zero, with no
`exited` line and an `Error:` line instead.
**Verification**
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
- Unit tests assert (reason string, exit code) for each path.
### 4. Cleanup on exit (no `kill -9`)
Ownership-based cleanup:
- If this run **created** the subscription (no `--subscribe-id`), a clean
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
it server-side and sends Bye.
- If `--subscribe-id` was passed (reusing an existing subscription), the
subscription is **left intact** — the caller owns its lifecycle.
- `--ephemeral` remains as an explicit "always unsubscribe" override.
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
server-side subscription: "subscription already exists" on restart,
duplicate delivery). Prefer SIGTERM or closing stdin.
**Verification**
- T4a: start consume (self-created subscription), record subscribe_id;
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
and the server-side subscription is gone.
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
subscription is still present (reuse case preserved).
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
### 5. Subscription-create retry orchestration and local guard
This policy covers all 16 public personal-event keys and every logical
subscription in a multi-event command. It applies only before the ready
marker; reconnecting an established Stream remains a separate mechanism.
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
process sends at most one subscription-create HTTP request for a logical
subscription and performs no in-process automatic retry. The CLI persists
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
not persist or enforce the Agent/host attempt count across invocations.
- ID resolution, `event consume`, and later `event status/stop` must use the
same `--profile`. A user or conversation ID resolved under another profile
must not be reused for the current subscription.
- A logical subscription is keyed by the current profile/identity, event key,
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
does not create a new logical operation or reset the Agent/host budget.
- For the Agent/host, `retryable=false` means
`max_additional_attempts=0`.
- For the Agent/host, `retryable=true` means
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
`next_retry_at` when present and must not retry early.
- For the Agent/host, an omitted retryable value
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
failure stops the operation.
- `in_flight` means the original logical request is still running.
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
it. These states must not recursively launch `event consume`, start a
parallel equivalent subscription, or bypass the guard with a new subId or
trace. The caller waits for the original request/guard or stops, while the
Agent/host keeps its own orchestration count.
- A multi-event command remains one original operation. A caller must not
split out a failed event, reorder events, or restart the command to bypass
a budget. Existing startup rollback cleans subscriptions created before a
later item fails.
#### Local guard state operations
- The default open-edition state file is
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
that edition's directory instead of `open`.
- The identity directory is mode `0700`; both
`personal_subscription_attempts.json` and
`personal_subscription_attempts.lock` are mode `0600`.
- A failure streak resets after 24h without another failure. A
`terminal_hold` lasts 1h. Prefer waiting until the reported
`next_retry_at`; do not clear the file as a normal retry mechanism.
- For emergency recovery, first ensure that no subscription-create process is
running for that identity. Delete only
`personal_subscription_attempts.json`, never the lock file. This clears
every protection record for that identity, not just one event.
**Verification**
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
contract and explicitly reject describing it as a CLI-persisted hard cap.
- T5b (CLI): one process issues at most one create request per logical
subscription; a changed subId/trace or process restart does not bypass the
persisted fingerprint guard.
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
and a partial startup still rolls back earlier subscriptions.
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
### 6. Host runtime-token handoff
When the root command carries an explicit host-supplied `--token`, personal
event control requests and the foreground Stream use that token with higher
priority than local OAuth. A detached bus receives it only through the
owner-only local IPC transport:
1. The child starts in runtime-token mode with non-sensitive identity and
ticket metadata only; neither its argv nor environment contains the token.
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
3. The bus advertises the additive `runtime_token_v1` capability and its
in-memory credential generation in `HelloAck`.
4. Only after that capability is confirmed does the consumer send a bounded
`credential_update` frame. The bus applies it with generation CAS, replies
with `credential_update_ack`, and registers the consumer only on success.
The bus blocks ticket acquisition until the first runtime credential arrives.
A later invocation may rotate Token A to Token B on a compatible existing bus;
the current WebSocket remains connected and the next ticket request or natural
reconnect uses B. If a 401 rejects the current runtime token, only an already
installed newer generation is retried; the runtime path never refreshes or
falls back to a local OAuth profile and never suggests `dws auth login`.
Clients do not send a token to a bus that lacks the capability, do not stop
other consumers automatically, and fail before printing the ready marker. With
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
new-bus protocol behavior remains unchanged.
**Verification**
- T6a: a stale local Token A and root Token B produce control and ticket
requests authenticated only with B.
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
401 retries only an already-installed newer runtime token.
- T6c: an old bus receives no credential and remains running; the new consumer
exits before its ready marker.
- T6d: a canary credential is absent from child argv/environment, dry-run,
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
continue to pass.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
down and takes consume with it (the likely cause of the observed silent
drop). Making more drops retryable, keeping the bus alive across a
reconnect, and emitting `reason: source_lost` only after exhausting the
budget — tracked on its own branch, since it needs error-classification
judgement and real flaky-network testing, and would otherwise couple clean
contract work with resilience work.
## Test surface
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
- Integration/e2e: `--foreground` + mock source (or a short real run) for
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
codes.
-270
View File
@@ -1,270 +0,0 @@
# flag / help / schema 同源
- **状态**:已决策(路径 A + Contract 嵌入 Schema)
- **相关**:[`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md)、[`schema-dynamic-endpoint-design.md`](schema-dynamic-endpoint-design.md)
- **实现门面**:`LeafSpec` → `corecmd.Spec` → `corecmd.New`
## 1. 决策
采用 **路径 A:Contract / LeafSpec 为 CLI 表面权威**,并且 **Contract 必须嵌入进 Schema**。
「同源」的含义是:同一份 Contract(今日经 LeafSpec / `corecmd.Spec` 表达)同时决定——且门禁能证明——
1. cobra 实际注册的 flags / required / defaults;
2. `--help` 的 Flags 与「参数约束」段;
3. 运行时**组装后的** Schema 交付中的 parameters、关系约束和 SafetySpec:
- **SchemaRegistry / Catalog ToolSpec wire** 供 `dws schema` / `--all` / 完整 leaf 载荷(lazy;首次 `dws schema` 触发完整装配)。
- **`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety** 与装配同源:`deliverySchemaCatalog` sync.Once 装配后同步物化 `map[cli_path]CommandMeta`;稳态为 O(1) map lookup,不为 Meta 单独重做全量 ToolSpec/wire 投影。
Agent metadata 在组装期间经内存 inject,不落盘、不 embed;`schema_agent_metadata/` 已退役,若存在则 policy 失败。
嵌入机制(已落地):
```text
corecmd.Spec
→ RegisterFlags + embedContractIntoSchema
→ cobra annotations:
dws.schema.contract=command
dws.schema.property / type / required (per flag)
dws.schema.constraints
→ RegisterRuntimeContractFinal(SafetySpec + ContractDecl)
→ Schema 组装透传 Contract Final
(组装时内存 inject Agent metadata)
→ RegisterSchemaSourceRoot → ResolveSchemaBuild
→ SchemaRegistry (+ Meta cache map) / dws schema wire projection
```
command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
受管命令进入 Schema 的叶子数据由 **Contract 声明**定义最终值;框架(`corecmd.New`)做**类型转换**并注册,Schema 组装**透传**,不得:
- 把声明序列化成 JSON 注解再解析;
- 在声明体系里再挂「评审字段」并行权威;
- 用 hints/registry 盖写已声明字段。
**declare-vs-delivery 例外(Title / Description)**:构造期 `ContractDecl.Description` **必填**(声明证据),但这不是「declare = wire 最终值」。Catalog **交付**时:
- **description**:有 Cobra Long → 交付 Long(provenance `cobra_help` / `cobra_help_preferred`);无 Long → 交付声明(`contract_final`)。**Short 不进入 description**。
- **title**:声明 `ContractDecl.Title` 优先,否则 Cobra Short,再 MCP;组装 stamp 真实 winner。
这是一条权威链上的显式交付偏好,不是双权威(见 RFC §5.0.4)。
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
**不采用**路径 B(以 MCP meta / 已退役的 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。
路径 B 仅允许作为 **可选的 1:1 MCP 透传叶子通道**(见 §5),不得覆盖 LeafSpec / Shortcut 主路径。
对外叙事:与飞书 **分层单权威** 同构——API/透传叶可用平台事实,产品 CLI / Shortcut 用手写契约 + 执行体——而不是「全家只有平台 meta」。
### 1.2 声明(declare):写什么、写在哪、投影到哪
**定义**:声明 = 在 Contract 结构体的**数据字段**上写出事实;`corecmd.New` 据此注册 cobra、渲染 help、写入 `dws.schema.*`。钩子闭包(`Validate` / `Call` / `PostMount` / `RunE`)里的逻辑**不算**声明——即使行为正确,也不能单靠钩子让 Schema/help「猜出」该事实。
命令框架边界与今日/目标对应见 RFC [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(框架上的「声明」定义);本节给字段级表与示例。
**唯一写入面**(三选一,语义相同):
| 入口 | 类型 | 归一 |
|---|---|---|
| Leaf 命令 | `helpers.LeafSpec` | `FromLeafSpec` → `corecmd.Spec` |
| Shortcut | Shortcut 声明(经 `FromShortcut`) | → `corecmd.Spec` |
| 直接基座 | `corecmd.Spec` | `corecmd.New` |
今日产品 CLI 叶子以 **`LeafSpec` 为声明门面**;字段与 `corecmd.Spec` 契约面一一对应。
#### 1.2.1 契约字段(算声明)
| 字段 | 声明什么 | 运行时 | 嵌入 Schema / help |
|---|---|---|---|
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
| `Safety`(`contract.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
`FlagSpec` 子字段(声明细节):
| 子字段 | 作用 | 是否进 Schema parameters |
|---|---|---|
| `Name`, `Usage`, `Kind`, `Default` | 注册名/说明/类型/DefValue | 是(name/type;default 与 cobra 对齐) |
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
#### 1.2.2 编排 / 执行字段(不算声明)
| 字段 | 角色 | 禁止用来「冒充」的声明 |
|---|---|---|
| `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` |
| `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) |
| `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate |
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 |
| `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 |
#### 1.2.3 最小声明示例
```go
// 读:Safety 四字段显式对齐 Schema
NewLeafCommand(LeafSpec{
Use: "get", Short: "…", Tool: "…",
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true},
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 写:同一个 SafetySpec 同时驱动确认与 Schema
NewLeafCommand(LeafSpec{
Use: "publish", Short: "…", Tool: "…",
Flags: []LeafFlag{ /* … */ },
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面
NewLeafCommand(LeafSpec{
Use: "create", /* Flags… */,
Validate: func(cmd *cobra.Command, _ []string) error {
return devAppRequireWriteGuard(cmd, "create") // 执行守卫,不是 Contract 声明
},
Call: devAppCall(runner),
PostMount: devAppMetaWrite(tool), // 人工标注 runtime_gate
})
```
**空 `Safety` 的含义**:command 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。
### 1.3 人工标注(annotate):声明的补充通道
当事实无法或不愿放进 Contract 字段时,必须**显式**落注解 / 评审源,禁止组装期推断:
| 标注手段 | 典型值 | 何时用 |
|---|---|---|
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) |
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;command/Leaf 禁止新增 |
| `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) |
| reviewed `schema_hints/metadata` Safety(已退役) | effect/risk/confirmation | 已删:`schema_hints/` 不得重现;受管命令以 Contract.Safety / gate 为准 |
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
### 1.4 Schema 全覆盖(`ToolSpec` 无空洞)
`dws schema` 叶子模型是 `cli.ToolSpec`。命令框架必须为**每一个字段组**指定权威;完整矩阵在 RFC **§5.0.4**,摘要:
| ToolSpec 组 | 权威类 | 框架声明字段 / 其它源 |
|---|---|---|
| Identity | 声明源(identity collector 收集 `ContractFinal.Identity`;reviewed `schema_command_registry` 已退役) | `ContractDecl.Identity` 声明 |
| Display / Title / Description | 声明证据 + 交付偏好(非双权威) | **title**:ContractDecl 优先,否则 Cobra Short,再 MCP;**description**:构造期 Description 必填;Catalog 交付 Long→`cobra_help`,无 Long→`contract_final`;**Short 不进 description**(RFC §5.0.4) |
| Parameters.`name/type/required/default/property` | **声明**(或同形 annotate) | `Flags` / `Bind` |
| Parameters.`description` | 声明 usage(`FlagSpec.Usage` / ParamDecl) | `schema_hints/` 已退役;不得用 overlay 改 type/required/default |
| Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** |
| Constraints | **声明** | `Constraints` |
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
| DryRun | 评审源 | dry-run capabilities registry |
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
| FieldProvenance / Extensions | 组装派生或评审扩展 | 组装器;与 delivered value 一致 |
| (非 Schema parameter)ConstParams | **声明** | 载荷;不上 parameters 表 |
验收:新增 Schema 字段必须同步改 RFC §5.0.4 + 本表;受管写命令 Safety 不得无主。
## 2. 字段归属(每一类恰好一个写入者)
| 字段类 | 权威 | 投影到 |
|---|---|---|
| flags / defaults / required / enum / 关系约束 / 运行时 Risk | Contract(LeafSpec / `corecmd.Spec` 门面) | cobra、`--help`、Schema `parameters` / constraints / confirmation |
| ConstParams、Bind、OmitEmpty、Transform | 同上(载荷声明,不上 flag 表) | toolArgs;Schema 不把 ConstParams 伪装成用户 flag |
| canonical path / aliases / navigation / exposure | identity collector 收集的 `ContractFinal.Identity` 声明(reviewed `schema_command_registry` 已退役) | Schema identity |
| use_when / avoid_when / examples / agent_summary 文案 | `ContractDecl.Selection` / `ProductDecl` | Schema selection |
| RPC tool 形状、`interface_ref`、interface 描述 | leaf `Contract.Interface` + `ParamDecl`(`schema_parameter_mapping_ledger.go` 仅 mapping_exclusions / removals;`schema_mcp_metadata` 已退役) | Schema `interface_*` 字段;**不得创建 flag** |
| 参数描述 overlay(可选) | 生产 metadata 壳为空;参数事实走 ParamDecl / FlagSpec | **Contract/cobra 胜** |
| 遗留 Safety 文案(迁移期) | 生产 metadata 壳为空;Safety 走 Contract | 以 Contract.Safety / runtime_gate 为准(见 §4) |
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
Selection **刻意不**由单命令 Contract 取代(RFC 决策 8 / schema 设计硬规则);identity 的历史决策是不进 Contract、归 reviewed `CommandRegistry`,该 registry 已退役,现由 identity collector 收集 `ContractFinal.Identity` 声明(声明即 identity)。**完整无空洞表见 RFC §5.0.4。**
## 3. 当前缺口与目标闭环
已具备:
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(command);
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 ContractDecl 注册为类型化 Contract Final 并由 Schema 组装透传;
- Selection 权威为 `ContractDecl.Selection` / `ProductDecl`(`contract_final`);`schema_hints/` 已退役。
已进 CI(`make policy` → `check-schema-catalog.sh` / `check-runtime-confirmation-truth.sh`):
| Gate ID | CI 入口(`-run` 白名单 / 脚本) |
|---|---|
| `HOM-P1` / `HOM-D1` | `./internal/app`:`TestFinalSchemaParametersMatchExecutableHelpFlags`、`TestDeliverySchemaParametersMatchExecutableHelpFlags` |
| `HOM-P2`(参数映射/bindings 子集) | `./internal/cli`:`TestSchemaParameterBindingsMatchReviewedBaselineAndDeliveryCatalog`、`TestDeliveryCatalogMCPParameterMappingsAreComplete` 等 bindings 门禁 |
| `HOM-S1` / `HOM-S2`(confirmation 同源) | `./internal/cli/homology`:`TestUserRequiredSafetyHomologyWithRuntimeGate` + `check-runtime-confirmation-truth.sh`;`./internal/app`:`TestSheetFinalSchemaConfirmationMatchesRuntimeGuards` |
| 词汇/决策钉扎 | `./internal/cli/homology`:`TestHomologyDecisionDocPinsPathAAndGateIDs`、`TestMCPPassthroughAdmissionExcludesLeafAndShortcut`、`TestHomologyCIEntrypointsPinned` |
仍缺(未宣称全量 CI 覆盖):
1. 独立可执行的 `HOM-P3`(constraints ≡ AnnotateConstraints)与 `HOM-S3`(read 不得误投影 user_required)全量 gate;
2. `HOM-I1` 作为单独 gate ID 的显式用例(MCP bindings ⊆ Contract flags 已有映射审计子集,但未钉 `HOM-I1` 标签)。
已落地(写命令确认语义,`HOM-S2`):
- `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注;
- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。
## 4. Schema 投影与 Safety 门禁规划
以下门禁 ID 稳定,便于 CI 认领。§3 表标明哪些已挂入 `check-schema-catalog.sh`。
| Gate ID | 断言 | 范围 | CI |
|---|---|---|---|
| `HOM-P1` | 受管 leaf 的 schema `parameters[].name` 集合 ≡ cobra 本地 flag 名集合(排除全局 persistent) | LeafSpec / Contract 编译命令 | **已进**(app help↔schema) |
| `HOM-P2` | schema parameter `type` / `required` / `default` 与 cobra DefValue / MarkFlagRequired / FlagSpec 一致;不得用已退役 hints overlay 改写这三项 | 同上 | **部分**(bindings/mapping 门禁) |
| `HOM-P3` | schema 关系约束(require_one_of / mutually_exclusive)≡ Contract/Leaf `Constraints` 投影(与 `AnnotateConstraints` 同构) | 声明了 Constraints 的命令 | 规划 |
| `HOM-S1` | Contract/Leaf `user_required` Safety 与运行时 Confirm/gate 同源,且 help Safety 行同语义 | 受管写/破坏性命令 | **已进** |
| `HOM-S2` | 若命令走显式 write guard(如 `devAppRequireWriteGuard`)而非完整 SafetySpec,则必须人工标注 `dws.schema.runtime_gate`;Schema 不得呈 `confirmation=not_required`;符合 §1.1 declare OR annotate | 今日 devapp 写命令 | **已进**(同源测试含 gate 路径) |
| `HOM-S3` | `Risk=read`(或空→read)不得投影为 `user_required`,除非有 reviewed exclusion reason | 受管读命令 | 规划 |
| `HOM-I1` | `interface_ref` 存在时,bindings 覆盖的 CLI flag ⊆ Contract flags;MCP meta **不**引入额外 CLI flag | 有 MCP 绑定的命令 | **部分**(mapping 审计) |
| `HOM-D1` | `dws <path> --help` Flags 段与 schema leaf parameters 零未解释增量 | 受管公开 leaf | **已进**(与 HOM-P1 同测) |
落地顺序建议:
1. 保持 `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` 在 `check-schema-catalog.sh` 白名单中(勿再只靠词汇钉扎);
2. 补独立 `HOM-P3` / `HOM-S3` 可执行 gate,并把 `HOM-P2`/`HOM-I1` 从「部分」升到全量标签;
3. 新 Leaf 继续走 Contract 嵌入;禁止 `schema_hints/` 回潮。
## 5. 路径 B 子通道:1:1 MCP 透传叶(可选,非主路径)
仅当同时满足以下条件时,才允许「从 MCP meta 生成 flag/help/schema 参数」:
1. CLI path ↔ 单一 MCP tool **严格 1:1**,无多步、无按名解析、无本地 effect;
2. 无不在 MCP input schema 中的 CLI 特有 flag(含 guard 专用语义 flag 除外的全局 `--yes`/`--dry-run`);
3. 无 ConstParams / Transform / 跨 flag 约束 / Call 内业务逻辑;
4. Risk/confirmation 在 meta 或并列 reviewed Safety 中有显式来源,不靠生成器猜测;
5. 在 identity 声明面标记 `surface_kind=mcp_passthrough`(名称可调整;原计划标在 reviewed registry,该 registry 已退役),且 **不得**与 LeafSpec/Shortcut 手写定义双注册同一 `cli_path`;
6. 文档与门禁写明:该通道是子集优化,失败时回退/禁止扩张到产品 CLI。
显式排除(永远走路径 A / Shortcut):
- 全部 `LeafSpec` 命令(含 `dws dev app …`);
- 全部 `+shortcut` 与 smart 编排;
- 任何需要 `devAppRequireWriteGuard`、cursor 工具注入、或响应投影的命令。
## 6. 非目标
- ~~不把 canonical identity / 导航塞进 Contract(仍归 reviewed `CommandRegistry`)~~ —— 该非目标已被后续演进取代:reviewed `CommandRegistry` 已退役,identity 现由 collector 收集 `ContractFinal.Identity` 声明(声明即 identity,不再是独立评审文件)。selection 文案已由 `ContractDecl.Selection` / `ProductDecl` 声明(非 hints)。
- 不要求删除 LeafSpec 门面。
- 不把「生成 catalog 字节一致」当作运行时同源的充分条件(仍需 `HOM-*` 与差分门禁)。
-154
View File
@@ -1,154 +0,0 @@
# International DingTalk (`.io`) Guide
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
## Region behavior
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
- Omitting `--intl` keeps the existing domestic `.com` behavior.
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
## Check availability
```bash
dws auth login --help
```
The help output must include `--intl` and `--international`.
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
```bash
make build
./dws auth login --help
```
## Log in
Browser login:
```bash
dws auth login --intl
```
Device flow for SSH, containers, and headless environments:
```bash
dws auth login --intl --device
```
User OAuth with custom application credentials:
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
## Verify the login
```bash
dws auth status --format json
dws profile list --format json
dws contact user get-self
```
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
## Use domestic and international profiles together
```bash
# Domestic (.com)
dws auth login
# International (.io)
dws auth login --intl
# Find the stable profile selectors
dws profile list --format json
```
Persistently switch profiles:
```bash
dws profile switch <corpId>:<userId>
```
Toggle back to the previous profile:
```bash
dws profile switch -
```
Select a profile for one command without changing the default:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
## Isolated smoke testing
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
## Pre-release overrides (maintainers only)
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
Maintainers can test the pre-release login/MCP pair with:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
## Troubleshooting
### The browser still opens a `.com` page
1. Run `dws auth login --help` and confirm `--intl` is present.
2. For a source checkout, use `./dws` instead of an older installed binary.
3. Confirm the executed command is `dws auth login --intl`.
### A business command appears to use the wrong region
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
### Login succeeds but the command reports missing permission
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
### Should I edit `~/.dws/mcp_url` manually?
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
## Command reference
| Scenario | Command |
|---|---|
| Domestic browser login | `dws auth login` |
| International browser login | `dws auth login --intl` |
| International device login | `dws auth login --intl --device` |
| Check auth state | `dws auth status --format json` |
| List profiles | `dws profile list --format json` |
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
| Toggle to previous profile | `dws profile switch -` |
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
-185
View File
@@ -1,185 +0,0 @@
# DWS 国际版(DingTalk `.io`)使用手册
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
## 核心规则
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
## 确认当前版本支持国际版
运行:
```bash
dws auth login --help
```
帮助中应包含:
```text
--intl
--international
```
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
```bash
make build
./dws auth login --help
```
## 国际版登录
### 浏览器登录
```bash
dws auth login --intl
```
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
### 设备码登录
适用于 SSH、容器或没有可用浏览器的环境:
```bash
dws auth login --intl --device
```
按照终端提示,在另一台可打开浏览器的设备上完成授权。
### 使用自有应用凭证完成用户 OAuth
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
## 验证登录和业务调用
查看当前登录状态:
```bash
dws auth status --format json
```
列出本机全部账号并找到当前 profile:
```bash
dws profile list --format json
```
执行一个只读命令验证国际链路,例如:
```bash
dws contact user get-self
```
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
## 国内版和国际版账号并存
可以在同一台机器上分别登录国内版和国际版账号:
```bash
# 国内版(.com)
dws auth login
# 国际版(.io)
dws auth login --intl
# 查看稳定的 profile 选择器
dws profile list --format json
```
持久切换账号:
```bash
dws profile switch <corpId>:<userId>
```
切回上一个账号:
```bash
dws profile switch -
```
只为单次命令指定账号,不修改默认账号:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
## 使用独立配置目录进行验证
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
## 预发参数(仅维护者)
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
维护者验证预发登录/MCP 链路时可以使用:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
## 常见问题
### 仍然打开 `.com` 登录页面
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
### 业务命令似乎使用了错误区域
先检查当前账号:
```bash
dws profile list --format json
```
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
### 登录成功但提示没有权限
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
### 是否需要手工修改 `~/.dws/mcp_url`
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
## 命令速查
| 场景 | 命令 |
|---|---|
| 国内版浏览器登录 | `dws auth login` |
| 国际版浏览器登录 | `dws auth login --intl` |
| 国际版设备码登录 | `dws auth login --intl --device` |
| 查看登录状态 | `dws auth status --format json` |
| 查看所有账号 | `dws profile list --format json` |
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
| 切回上一个账号 | `dws profile switch -` |
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
@@ -1,134 +0,0 @@
# 独立 `meta.pagination` Schema 方案
## 1. 目标结构
业务结果与分页控制信息分层:
```json
{
"ok": true,
"outcome": "success",
"data": {
"items": [{"id": "a"}]
},
"meta": {
"pagination": {
"endpoint_exhausted": false,
"next_token": "cursor-2"
}
}
}
```
对应 compact/full leaf Schema:
```json
{
"result": {
"outcomes": ["success", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "当前页业务记录",
"items": {"type": "object"}
}
}
}
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
## 2. 分页状态
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|---|---:|---|---|
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
健康、数据全量覆盖或业务对象不存在。
## 3. 映射规则
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
等字段,但统一 CLI 输出只公布 `meta.pagination`:
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
`pagination_inconsistent`,禁止伪装终页。
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
## 4. Schema 规则
- `kind` 当前只允许 `cursor`。
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
同一 leaf 的 `parameters`。
- 三个 meta path 由框架固定生成,产品不能覆盖。
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
## 5. 渐进接入
1. **legacy_only**:保持原输出,不公布分页声明。
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
legacy 字节不变。
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
`pagination` 声明。
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
不增加 `contract_version`、`--output-contract` 或分页协议别名。
## 6. 验收
每个分页命令至少验证:
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
4. `cursor_parameter` 在 Help/Schema 中真实存在。
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
6. `data_schema` 不包含分页控制字段。
7. 运行时 `data` 不包含迁移后的分页控制字段。
8. dual validate 与 active 都只消费一次上游响应。
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
### DevApp 首批落地
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
`meta.pagination` 返回分页控制信息:
- `dev app list`
- `dev app permission list`
- `dev app event list`
- `dev app version list`
- `devapp +list`
- `devapp +permission-list`
- `devapp +event-list`
- `devapp +version-list`
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
`hasMore/nextCursor`。
## 7. 对齐依据
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
File diff suppressed because it is too large Load Diff
+25 -144
View File
@@ -5,116 +5,13 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 |
| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Version and Extended Context / Agent 版本与扩展上下文
`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary,
non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or
the separate `X-Cli-Version` that identifies the DWS CLI version, and they are
not forwarded to A2A, OAuth, Discovery, or third-party plugin requests.
`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The
recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps
the object extensible and does not enforce a key allowlist. For example, using
fictional, redacted values:
```bash
DWS_AGENT_VER=0.1.5
DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
```
The shell's outer single quotes group the JSON and are not part of the
environment-variable value. The CLI trims surrounding ASCII spaces/tabs,
omits either Header when its value is empty, and compacts EXT to a single-line
JSON object. A representative current payload is about 657 bytes, well below
the 8 KiB limit; integrations must still enforce the limit because values can
grow. EXT may contain sensitive device or runtime signals: the CLI masks it in
configuration and logs, and removes it on a cross-host redirect.
Both values are declared by the caller and are therefore forgeable. They can
support compatibility checks, diagnostics, and observability, but they are not
credentials or attestations and must never be sufficient on their own to
authenticate a caller or authorize access.
`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会
改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`;
二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象
请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制
扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载
约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的
设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。
这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性,
但不是凭据或可信证明,不能单独用于身份认证或访问授权。
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
signals. They are not credentials, attestations, or proof of the calling
host's identity. The CLI validates and emits `x-dws-agent-product` and
`x-dws-agent-host`, but does not use either value to derive its authentication,
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
services own and must document their own contracts for these caller-declared
Headers.
Service integrators should treat both Headers as untrusted input, allowlist
expected values, and should not grant access, bypass authentication, or skip
authorization solely because a Header claims a particular Product or Host.
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
however, a valid non-empty Product value is used as the `clawType` tool
argument so the delivered message carries the matching “Send from AI” label.
Because `--ai-tag` defaults to `true`, this display change is enabled by
default for callers that set Product. With `--ai-tag=false`, native
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
calls omit the argument; this client does not assume downstream services treat
an empty value and an absent key as equivalent. The display-value precedence
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
edition's `ClawTypeValue`, then `openClaw`.
Do not set arbitrary Product values that the target downstream and IM services
have not explicitly enabled; an unknown value may be ignored or may not render
the expected label.
For QwenWork, report the dimensions separately:
```bash
DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
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` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
@@ -125,7 +22,7 @@ IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
@@ -137,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
```
## Dry Run / 试运行
@@ -154,60 +51,44 @@ dws contact user search --query "Alice" -o result.json
## Schema Introspection / Schema 查询
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本运行时组装的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树叶节点上的 `ContractFinal.Identity`(`CollectIdentitySpecs`),并在发布时逐项绑定当前 Cobra tree。原 reviewed `schema_command_registry/` 已退役,身份变化通过编辑叶节点声明完成。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
### 路径写法
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
dws schema # 列出所有产品 + 工具名
dws schema ding.send_ding_message # canonical: product.rpc_name
dws schema ding.message.send # CLI 点路径: product.group.cli_name
dws schema "ding message send" # CLI 空格路径(同上)
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
### 单工具输出字段
| 字段 | 说明 |
|------|------|
| `canonical_path` / `primary_cli_path` / `aliases` | 稳定工具 ID、主 CLI 路径和兼容路径 |
| `product_id` / `interface_ref` | CLI 产品与实际 MCP product/RPC binding |
| `title` / `description` / `agent_summary` | 人类说明、接口说明和 Agent 摘要 |
| `parameters.<flag>` | CLI flag 的类型、属性名、required、默认值、格式、枚举和条件必填 |
| `constraints` | one-of、互斥、联动等组合约束 |
| `effect` / `risk` / `confirmation` / `idempotency` | Agent 执行与安全策略 |
| `use_when` / `avoid_when` / `examples` | Agent 选择提示和示例 |
| `reviewed` / `agent_source_refs` | 语义审核状态与来源追踪 |
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
| `group` | CLI 父级 group 路径(dot-separated) |
| `title` / `description` | 工具名/说明(overlay 优先) |
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` |
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
`parameters.<flag>.required` 是按来源 precedence 解析后的 Agent 参数契约;`cli_required=true` 才表示 Cobra 将该 flag 标记为硬必填。条件必填或别名选择通过 `required_when` 和 `constraints.require_one_of` 表达。`required` 不直接复制 MCP input schema,也不取代 Cobra 的实际执行校验。
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
### 筛选输出
```bash
dws schema "calendar event create" --jq '.parameters' # 只看参数
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
```
## Shell Completion / 自动补全

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