Compare commits

..
Author SHA1 Message Date
ugurtafrali 1ea8bad074 Reduce MCP discovery timeouts
When MCP plugins are unreachable, the discovery times out too slowly and blocks commands. Reduced the overall discovery timeout from 10s to 4s and per-server timeout from 5s to 2s. Made the per-server timeout configurable.
2026-04-17 14:40:41 +08:00
2124 changed files with 32959 additions and 628883 deletions
-5
View File
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
-5
View File
@@ -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.
-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。评审者根据改动是否可见来判断该
例外是否成立。
-5
View File
@@ -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.
+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: 48.7%"><title>coverage: 48.7%</title><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="#e05d44"/><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=".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=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 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;
}
+71 -1394
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
-121
View File
@@ -1,121 +0,0 @@
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
name: Mirror code to Gitee
on:
push:
branches:
- main
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' }}
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout main history
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push main + tags to Gitee (with README localization)
if: env.GITEE_TOKEN != ''
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'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
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。
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
-56
View File
@@ -1,56 +0,0 @@
name: Main Integration — 主干集成
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: multi-profile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
multi-profile-e2e:
name: Multi-profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated multi-profile chain
shell: bash
run: |
set -o pipefail
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
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"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: multi-profile-e2e-debug
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();
-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"
-41
View File
@@ -16,10 +16,8 @@ dws
test/cli/testdata/
tmp/
test/cli_compat/testdata/
/internal/compat/testdata/*
.gitignore
.worktrees/
.qoder/
# Secrets & credentials
.env
@@ -27,45 +25,6 @@ test/cli_compat/testdata/
*.pem
*.key
credentials*
!skills/**/credentials.md
plans
_docs
dws.zip
*.code-workspace
/dingtalk-workspace.zip
# envelope/discovery.pre.json synced via Portal, not git-tracked
/envelope/discovery.pre.json
# local/pre-release MCP service configs may contain personal gateway keys
/docs/mcp/serviceconfig-pre*
# 功能测试运行产物
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
+9 -7
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
@@ -59,12 +64,9 @@ changelog:
release:
github:
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
owner: DingTalk-Real-AI
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.
-1724
View File
File diff suppressed because it is too large Load Diff
+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 flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag 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.58-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.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
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.57"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
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 every default Go package belongs to one CI test shard\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 -422
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -63,27 +63,6 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill mode: mono vs multi</strong></summary>
The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
| 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 |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
</details>
<details>
<summary>Other install methods</summary>
@@ -93,30 +72,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,44 +88,10 @@ 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>
## China mirror
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
**1. Install script + pre-built binary (Gitee mirror):**
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
**2. npm package (npmmirror mirror):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
**3. Skills only (Gitee mirror):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -180,46 +101,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 detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
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 |
@@ -280,75 +182,15 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
</details>
<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.
```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
```
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.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow:
```bash
# Sandbox A (already logged in)
dws auth export -o /tmp/dws-auth.tar.gz
# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64
# Sandbox B
dws auth import -i /tmp/dws-auth.tar.gz
# Or: dws auth import -i /tmp/dws-auth.b64 --base64
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>
## Quick Start
```bash
dws contact user search --query "engineering" # search contacts
dws calendar event list # list today's calendar events
dws doc search --query "quarterly" # search DingTalk Docs
dws minutes list mine # list AI meeting notes I created
dws drive list # list DingTalk drive files
dws contact user search --keyword "engineering" # search contacts
dws calendar event list # list calendar events
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
dws todo task list --dry-run # preview without executing
```
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
## Using with Agents
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
@@ -360,99 +202,49 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# Use --dry-run to preview operations (safe execution)
dws contact user search --query "engineering" --dry-run
dws contact user search --keyword "engineering" --dry-run
# Use --jq to extract precisely (save tokens)
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
# 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:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
The repo ships a complete Agent Skill system (`skills/`). 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
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
**Switching or re-installing with `dws skill setup`:**
```bash
# 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
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
**What's included (mono layout):**
**What's included:**
| Component | Path | Description |
|-----------|------|-------------|
| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| 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 |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
<details>
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
@@ -479,134 +271,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 six 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 six 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_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>
`dws api` lets you call any DingTalk OpenAPI without an SDK. Tokens are automatically acquired and refreshed.
> **Prerequisite**: Must login with your own app credentials (see [Custom App mode](#getting-started)). Encrypted tokens from MCP default-credential login are not supported for raw API calls.
```bash
# Login (first time only)
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
# === api.dingtalk.com ===
# List all enterprise apps
dws api GET /v1.0/microApp/allApps
# Search users (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"engineering","offset":0,"size":10}'
# === oapi.dingtalk.com ===
# Get user details (use --base-url to specify domain)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"<USER_ID>"}'
# Or use the full URL directly
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"<USER_ID>"}'
# === General ===
dws api GET /v1.0/microApp/allApps --page-all # auto-paginate
dws api GET /v1.0/microApp/allApps --dry-run # preview request
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq filtering
```
| Feature | Details |
|---------|----------|
| Dual-form auto-detection | Automatically selects api.dingtalk.com (header auth) or oapi.dingtalk.com (query-param auth) based on URL |
| Automatic token management | App-level accessToken is fetched on first call, cached while valid, auto-refreshed on expiry |
| Domain allowlist | Only `api.dingtalk.com` and `oapi.dingtalk.com` permitted — prevents token leakage |
| Auto-pagination | `--page-all` iterates all pages. `--page-limit` caps the maximum (default 10, set to 0 for unlimited, hard cap at 500 to prevent infinite loops) |
</details>
<details>
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
@@ -617,7 +281,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
# Sticky argument splitting
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
# Fuzzy flag name matching
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
@@ -642,7 +306,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
@@ -651,13 +315,13 @@ 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 --jq '.products[].id' # extract all product IDs
```
</details>
@@ -679,65 +343,31 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "Weekly Report" --text @-
```
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
</details>
## DingTalk bot — connect a robot to your local AI
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
```bash
dws dev connect --channel auto --unified-app-id <unifiedAppId>
```
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
> so the secret never appears in argv (`ps` / journald / shell history). The
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
> the CLI will warn you.
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
| Command | Effect |
|---------|--------|
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
## Key Services
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users, departments, labels, roster profiles and dismissals; create enterprises and enterprise accounts; invite employees |
| 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 |
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
| Ding | `ding` | Send / recall DING messages |
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
| Live | `live` | List my live streams |
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
<details>
<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
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
</details>
@@ -786,10 +416,8 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Reference & Docs
- [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
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Changelog](./CHANGELOG.md) — release history and migration notes
## Contributing
+50 -411
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
<details>
<summary><strong>目录</strong></summary>
@@ -63,27 +63,6 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
</details>
<details>
<summary>其他安装方式</summary>
@@ -93,30 +72,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,40 +89,9 @@ cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
</details>
## 国内加速安装
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
**2. npm 包(npmmirror 镜像):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -177,46 +101,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` | 跳过技能包更新 |
@@ -277,72 +182,15 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
</details>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉账号,同一组织也能保留多个账号。一个 profile 由 `corpId + userId` 唯一确定。
```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 "..." # 单次精确指定账号,不改默认账号
```
支持 `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`。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入:
```bash
# A 沙箱(已登录)
dws auth export -o /tmp/dws-auth.tar.gz
# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64
# B 沙箱
dws auth import -i /tmp/dws-auth.tar.gz
# 或:dws auth import -i /tmp/dws-auth.b64 --base64
dws auth status # 确认 Refresh Token: 有效
```
包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。
</details>
## 快速开始
```bash
dws contact user search --query "悟空" # 搜索联系人
dws calendar event list # 查看今天的日程
dws doc search --query "季度" # 搜索钉钉文档
dws minutes list mine # 列出我创建的 AI 听记
dws drive list # 列出钉盘文件
dws contact user search --keyword "悟空" # 搜索联系人
dws calendar event list # 查看日历日程
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
dws todo task list --dry-run # 预览操作但不执行
```
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
## 在 Agent 中使用
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
@@ -354,99 +202,49 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# 使用 --dry-run 预览操作(安全执行)
dws contact user search --query "张三" --dry-run
dws contact user search --keyword "张三" --dry-run
# 使用 --jq 精确提取(节省 token)
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/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
# 安装 skills 到当前项目
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/` 子目录。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
**用 `dws skill setup` 切换或重装:**
```bash
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--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 版本。
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
**包含内容(mono 布局):**
**包含内容:**
| 组件 | 路径 | 说明 |
|------|------|------|
| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
@@ -473,134 +271,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_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>
`dws api` 让你直接调用任意钉钉 OpenAPI,无需 SDK,Token 自动获取和刷新。
> **前置条件**:必须使用自有应用凭证登录(见[自建应用模式](#开始使用))。通过 MCP 默认凭证登录 不支持 raw API 调用。
```bash
# 登录(仅首次)
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
# === api.dingtalk.com ===
# 获取企业所有应用列表
dws api GET /v1.0/microApp/allApps
# 搜索用户 (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"张三","offset":0,"size":10}'
# === oapi.dingtalk.com ===
# 获取用户详情(使用 --base-url 指定域名)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"<USER_ID>"}'
# 也可以直接使用完整 URL
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"<USER_ID>"}'
# === 通用功能 ===
dws api GET /v1.0/microApp/allApps --page-all # 自动翻页
dws api GET /v1.0/microApp/allApps --dry-run # 预览请求
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
```
| 特性 | 说明 |
|------|------|
| 双形态自动识别 | 根据 URL 自动选择 api.dingtalk.com(Header 认证)或 oapi.dingtalk.com(Query 参数认证) |
| Token 自动管理 | 首次调用自动获取应用级 accessToken,有效期内缓存,过期自动刷新 |
| 域名白名单 | 仅允许 `api.dingtalk.com` 和 `oapi.dingtalk.com`,防止 Token 泄露 |
| 自动分页 | `--page-all` 自动遍历所有分页。`--page-limit` 控制翻页上限(默认 10,设为 0 不限制,硬上限 500 防止死循环) |
</details>
<details>
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
@@ -611,7 +281,7 @@ dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
# 粘连参数自动拆分
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
# 拼写错误模糊匹配
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
@@ -636,7 +306,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
@@ -645,13 +315,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>
@@ -673,60 +343,31 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "周报" --text @-
```
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
</details>
## 钉钉机器人 —— 把机器人接到你本地的 AI
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
```
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
| 指令 | 作用 |
|------|------|
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
## 核心服务
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职;创建企业、企业账号及邀请员工 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
| DING | `ding` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
| 直播 | `live` | 查看我的直播列表 |
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
<details>
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
</details>
@@ -777,10 +418,8 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 参考与文档
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [更新日志](./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
+35 -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,41 @@ __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, ".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")),
]
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
+9 -665
View File
@@ -3,7 +3,6 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const childProcess = require("child_process");
@@ -13,11 +12,8 @@ const AGENT_DIRS = [
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -26,7 +22,6 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
@@ -47,58 +42,6 @@ function ensureCleanDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// backupStamp returns the UTC timestamp used for backup directory names,
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
function backupStamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
// installs cannot confirm, so removals must stay reversible). Missing paths
// are a no-op success. On any backup failure the directory is left in place
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return true;
}
const rel = path.relative(homeDir, dir);
const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("-")
: path.basename(dir);
const stamp = backupStamp();
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
let targetRoot = path.join(backupRoot, stamp);
let target = path.join(targetRoot, name);
for (let i = 1; fs.existsSync(target); i++) {
if (i > 1000) {
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
return false;
}
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
target = path.join(targetRoot, name);
}
try {
fs.mkdirSync(targetRoot, { recursive: true });
renameFn(dir, target);
} catch (err) {
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
return false;
}
if (backups) {
backups.push({ original: dir, backup: target });
}
console.log(` × 已备份并移除 ${dir} → ${target}`);
return true;
}
function findBinary(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
@@ -171,587 +114,24 @@ function copyChildren(srcDir, destDir) {
}
}
// publishCacheAtomically prepares a complete sibling tree before replacing a
// cache. If copying or publishing fails, the previous cache stays available.
// copyFn is injectable so the failure contract can be tested without relying
// on platform-specific permission behavior.
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
const cacheParent = path.dirname(cacheDir);
const cacheName = path.basename(cacheDir);
fs.mkdirSync(cacheParent, { recursive: true });
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
let rollbackDir = "";
let published = false;
try {
copyFn(sourceDir, stagedDir);
if (fs.existsSync(cacheDir)) {
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.renameSync(cacheDir, rollbackDir);
}
try {
fs.renameSync(stagedDir, cacheDir);
published = true;
} catch (publishErr) {
if (rollbackDir) {
try {
fs.renameSync(rollbackDir, cacheDir);
rollbackDir = "";
} catch (restoreErr) {
throw new Error(
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
);
}
}
throw publishErr;
}
if (rollbackDir) {
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn(
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
);
}
rollbackDir = "";
}
} finally {
if (!published) {
fs.rmSync(stagedDir, { recursive: true, force: true });
}
}
}
function installSkillsToHomes(skillRoot) {
const homeDir = os.homedir();
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
installed += 1;
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
}
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
// Frozen exact names shipped before centralized ownership metadata. Retired
// names stay here so old installs can be migrated without treating every
// dingtalk-* directory as DWS-owned.
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
]);
function skillStateDir(homeDir) {
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
}
function readManagedSkillNames(homeDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
} catch (_) {
return new Set();
}
}
function isManagedMultiSkillDir(dir, managedNames) {
const name = path.basename(dir);
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
const backups = [];
try {
for (const victim of victims) {
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
} catch (err) {
const restoreErrors = [];
for (let i = backups.length - 1; i >= 0; i -= 1) {
try {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
} catch (restoreErr) {
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(full, rel);
} else {
files.push({ rel, full });
}
}
};
visit(dir, "");
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file.rel, "utf8");
hash.update(Buffer.from([0]));
hash.update(fs.readFileSync(file.full));
hash.update(Buffer.from([0]));
}
return `sha256:${hash.digest("hex")}`;
}
// Publish a complete multi-skill set as one transaction. The entire new set
// is staged before any Agent-visible directory moves. If a later backup or
// publish fails, every partial publication is removed and all old directories
// are restored from their exact backup paths.
function publishManagedMultiSkillSetAtomically(
homeDir,
multiRoot,
baseDir,
skills,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
for (const name of skills) {
const stagedDir = path.join(stageRoot, name);
copyFn(path.join(multiRoot, name), stagedDir);
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
renameFn(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
// Publish mono plus every mutually-exclusive managed multi victim as one
// transaction. The complete dws/ tree is staged before any live directory is
// moved; a later backup or publish failure restores the exact previous set.
function publishManagedMonoSkillSetAtomically(
homeDir,
monoRoot,
baseDir,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
const stagedDir = path.join(stageRoot, "dws");
const destDir = path.join(baseDir, "dws");
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
copyFn(monoRoot, stagedDir);
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
published.push(destDir);
renameFn(stagedDir, destDir);
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
function writeSkillsState(homeDir, multiRoot, skills) {
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
const managedSkills = [...skills].sort().map((name) => ({
name,
version,
source: "npm-postinstall",
digest: skillDirectoryDigest(path.join(multiRoot, name)),
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
}));
const state = {
version,
official_skills: [...skills].sort(),
updated_skills: [...skills].sort(),
managed_skills: managedSkills,
updated_at: new Date().toISOString(),
};
const stateDir = skillStateDir(homeDir);
fs.mkdirSync(stateDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
const stagedFile = path.join(stage, "skills-state.json");
const statePath = path.join(stateDir, "skills-state.json");
const rollbackPath = path.join(stage, "skills-state.previous.json");
let movedPrevious = false;
let preserveRecovery = false;
try {
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
if (fs.existsSync(statePath)) {
fs.renameSync(statePath, rollbackPath);
movedPrevious = true;
}
try {
fs.renameSync(stagedFile, statePath);
} catch (err) {
if (movedPrevious && !fs.existsSync(statePath)) {
try {
fs.renameSync(rollbackPath, statePath);
movedPrevious = false;
} catch (restoreErr) {
preserveRecovery = true;
throw new Error(
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
);
}
}
throw err;
}
} finally {
if (!preserveRecovery) {
fs.rmSync(stage, { recursive: true, force: true });
}
}
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
// present in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
// Mutual exclusion: include the mono leftover and stale managed skills in
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) {
victims.push(path.join(baseDir, name));
}
try {
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
}
writeSkillsState(homeDir, multiRoot, skills);
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
publishCacheAtomically(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
publishCacheAtomically(multiSource, multiCache);
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
}
}
@@ -759,9 +139,7 @@ function main() {
const packageRoot = __dirname;
const assetsDir = path.join(packageRoot, "assets");
const vendorDir = path.join(packageRoot, "vendor");
// Extract dws-skills.zip into a staging directory so we can split mono/
// (installed to agent homes) from multi/ (cached for later setup use).
const skillsStaging = path.join(packageRoot, "share", "skills");
const skillDir = path.join(packageRoot, "share", "skills", "dws");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (!assetName) {
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
@@ -777,42 +155,8 @@ function main() {
}
extractArchive(archivePath, vendorDir);
extractSkills(skillsPath, skillsStaging);
// For backward compatibility, the zip root carries a copy of mono content
// (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
// when present; fall back to the staging root otherwise.
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
extractSkills(skillsPath, skillDir);
installSkillsToHomes(skillDir);
}
if (require.main === module) {
main();
}
module.exports = {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
};
main();
-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)
}
}
-284
View File
@@ -1,284 +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",
)
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 *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag 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 *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 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]")
}
-577
View File
@@ -1,577 +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 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 hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
return true
}
}
return false
}
+1 -3
View File
@@ -19,8 +19,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
)
var exit = os.Exit
func main() {
exit(app.Execute())
os.Exit(app.Execute())
}
-27
View File
@@ -1,27 +0,0 @@
package main
import (
"os"
"testing"
)
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
previousExit := exit
previousArgs := os.Args
t.Cleanup(func() {
exit = previousExit
os.Args = previousArgs
})
called := false
code := -1
exit = func(value int) {
called = true
code = value
}
os.Args = []string{"dws", "version"}
main()
if !called || code != 0 {
t.Fatalf("main exit = called %v, code %d", called, code)
}
}
-93
View File
@@ -1,93 +0,0 @@
# Agent identification (agent_code & agentId)
dws tags every MCP request with **which agent host is driving it** and a
**per-instance id**, so usage can be sliced by channel/instance in the data
warehouse. This page is the integration contract.
## What dws sends on the wire
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
`x-dws-agent-id` keeps its original machine-level meaning for backward
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
clients send no `agent_code` / instance id — treat their absence as
"legacy/unknown", not an error.
## How `agent_code` is resolved (confidence ladder)
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
Auto-detection cannot cover every agent: most terminal agents (gemini/
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
continue, …) expose **no reliable self-identifying env var** — only user-set
API keys, which must not be used as identity. The robust answer is: **the host
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
server.** This is accurate for any agent, on any OS, and is future-proof.
MCP server config example (JSON-style hosts):
```jsonc
{
"mcpServers": {
"dingtalk-workspace": {
"command": "dws",
"args": ["mcp", "..."],
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
}
}
}
```
### Canonical codes
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
verbatim so PAT grants and follow-up command checks use the same key.
## Trust & limitations — READ THIS
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
self-reported, best-effort signals, NOT an authenticated identity.**
- `agent_code`: every declaration/auto-detect signal is an env var the
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
`claudecode`).
- The ids are **even easier to forge**: they are generated, stored, and sent
entirely client-side. `machineId` is a random UUID in the plaintext
`~/.dws/identity.json` (which the user owns), and the instance id is just
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
header — lets anyone mint, split, rotate, or impersonate ids at will. The
`dwsa_` prefix does NOT make it a secure identifier.
- ✅ **Fit for statistics / observability** (the intended use): there is no
incentive to misreport one's own agent, and real hosts emit real signals, so
aggregate per-channel metrics are reliable in practice.
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
revocation.** Anything where a party benefits from lying must not trust this
field. For control-plane use you need a gateway-issued **authoritative**
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
heavier mechanism, deliberately out of scope here.
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
## Gateway side (required for the data to land)
dws sending the headers is necessary but not sufficient. The gateway must:
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
to the upstream-header pass-through allowlist (otherwise they are stripped);
2. log them as fields, and deliver them to the warehouse (alongside the
existing flow-control / execution logs).
+15 -87
View File
@@ -1,98 +1,26 @@
# 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.
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.
1. `internal/market` fetches the registry and server metadata.
2. `internal/discovery` resolves runtime server capabilities and caches results.
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
## 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/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection, identity
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `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
- `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
- `internal/app`: root command wiring and static utility commands
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
- `internal/ir`: canonical intermediate representation for discovered tools
- `internal/generator`: docs, schema, and skill generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
- `skills/`: bundled agent skills source and generated skill docs
- `test/`: CLI, compatibility, integration, contract, and script tests
## Quality Pipeline
## Public Repository Contract
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.
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
+25 -94
View File
@@ -13,44 +13,40 @@ repository root while preserving repo-local guidance for automation.
## Project Snapshot
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
- Compatibility and helper surfaces are overlays, not the canonical truth.
## Repository Map
- `cmd`: public CLI entrypoint
- `internal/app`: root command wiring, static utility commands, plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and static endpoint loader
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection
- `internal/audit`: user operation audit log
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/app`: root command wiring and command tree mount points
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
- `internal/generator`: CLI/schema/docs/skills generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
- `docs/`: public architecture and reference docs
- `hack/`: developer-only helper commands not shipped as public binaries
- `scripts/`: build, test, lint, packaging, and policy checks
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
- `test/`: integration, contract, compatibility, and script validation suites
## Task Routing
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
- Protocol or transport issues: inspect `internal/transport`
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
- 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`
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
- Generated output drift: inspect `internal/generator` and run drift checks
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
## Policy Checks
## Generated Artifacts
When command surface or plugin descriptors change, run:
Prefer editing source logic instead of generated files directly.
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-open-source-assets.sh`
- Generated-heavy paths:
- `docs/generated/`
- `skills/generated/`
- `test/golden/generated_outputs/`
- When generator or command surface changes, run:
- `./scripts/policy/check-generated-drift.sh`
- `./scripts/policy/check-command-surface.sh --strict`
## Common Commands
@@ -59,77 +55,12 @@ make build
make test
make lint
./scripts/dev/ci-local.sh
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/policy/check-open-source-assets.sh
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:
-255
View File
@@ -1,255 +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
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 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. 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.
-176
View File
@@ -1,176 +0,0 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 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 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。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
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(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 漂移门禁 |
-328
View File
@@ -1,328 +0,0 @@
# dws Command Index
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
## Global flags
Every command inherits these flags (documented here once, not repeated per command):
| Flag | Purpose |
|---|---|
| `--client-id` | Override OAuth client ID (DingTalk AppKey) |
| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) |
| `--debug` | Enable debug logging |
| `--dry-run` | Preview the request without executing |
| `--fields` | Comma-separated output field projection |
| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) |
| `--jq` | jq expression applied to JSON output |
| `--mock` | Return mock data (developer aid) |
| `-o, --output` | Write output to a file |
| `--timeout` | HTTP request timeout in seconds (default 30) |
| `--token` | Override the configured API token |
| `-v, --verbose` | Verbose logging |
| `-y, --yes` | Skip confirmation prompts (AI-agent mode) |
## Contents
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`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 report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
## `dws aitable` — AI Tables
_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._
**41 commands**
| Command | Description | When to use |
|---|---|---|
| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. |
| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. |
| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. |
| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. |
| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. |
| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. |
| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. |
| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. |
| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. |
| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. |
| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. |
| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. |
| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. |
| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. |
| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. |
| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. |
| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. |
| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. |
| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. |
| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. |
| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. |
| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. |
| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. |
| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. |
| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. |
| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. |
| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. |
| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. |
| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. |
| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. |
| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. |
| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. |
| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. |
| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. |
| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. |
| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. |
| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. |
| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. |
| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. |
| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. |
| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. |
## `dws attendance` — Attendance
_Attendance check-in records, shifts, and aggregate statistics._
**4 commands**
| Command | Description | When to use |
|---|---|---|
| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. |
| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. |
| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. |
| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. |
## `dws calendar` — Calendar
_Calendar events, participants, meeting rooms, and busy-status queries._
**14 commands**
| Command | Description | When to use |
|---|---|---|
| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. |
| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. |
| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. |
| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. |
| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. |
| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. |
| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. |
| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. |
| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. |
| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. |
| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. |
| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. |
| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. |
| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. |
## `dws chat` — Group Chat / IM
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. |
| `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-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. |
| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. |
| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. |
| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. |
| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. |
| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. |
| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. |
| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. |
| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. |
| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. |
## `dws contact` — Contact Directory
_Users, departments, directory lookups, and enterprise onboarding._
**9 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. |
## `dws devdoc` — Open Platform Docs
_Search the DingTalk Open Platform documentation._
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, traceId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
_Send and recall DING messages (priority notifications)._
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. |
| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. |
## `dws doc` — DingTalk Doc
_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._
**21 commands**
| Command | Description | When to use |
|---|---|---|
| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. |
| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. |
| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. |
| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. |
| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. |
| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. |
| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. |
| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. |
| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. |
| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. |
| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. |
| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. |
| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. |
| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. |
| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. |
| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. |
| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. |
| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. |
| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. |
| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. |
| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. |
## `dws drive` — DingTalk Drive
_DingTalk Drive file and folder management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. |
| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. |
| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. |
| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. |
| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. |
| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. |
## `dws minutes` — AI Minutes
_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._
**19 commands**
| Command | Description | When to use |
|---|---|---|
| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. |
| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. |
| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. |
| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. |
| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. |
| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. |
| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. |
| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. |
| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. |
| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. |
| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. |
| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. |
| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. |
| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. |
| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. |
| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. |
| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. |
| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. |
| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. |
## `dws oa` — OA Approval
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
**12 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. |
| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. |
| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. |
| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. |
| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. |
## `dws report` — Reports
_DingTalk Report feature: templates, entries, and statistics._
**7 commands**
| Command | Description | When to use |
|---|---|---|
| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. |
| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. |
| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). |
| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. |
| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. |
| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. |
| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. |
## `dws todo` — Todo Tasks
_Personal todo task management._
**6 commands**
| Command | Description | When to use |
|---|---|---|
| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. |
| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. |
| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. |
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
-148
View File
@@ -1,148 +0,0 @@
# Running the connector as a 7x24 service
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options.
> **Security**: prefer `--unified-app-id <uappid>` over
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
> so the secret never appears in `ps` / journald / shell history. Pasting
> `--robot-client-secret` onto argv lets any local user read your AppSecret
> with `ps -ef`; the CLI will warn you when you do that.
## Option A: built-in daemon (recommended for a quick start)
```bash
# Detach into a background supervisor that restarts the connector if it crashes.
dws dev connect --daemon \
--channel claudecode \
--unified-app-id <unifiedAppId>
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
dws dev connect status --unified-app-id <unifiedAppId>
dws dev connect stop --unified-app-id <unifiedAppId>
dws dev connect restart --unified-app-id <unifiedAppId>
```
- The parent prints the daemon pid and the log path, then exits.
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
worker connector alive, restarting it with exponential backoff (1s..60s, up to
10 consecutive fast failures) when it exits abnormally.
- The single-instance lock (one connector per robot per machine) is reused, so a
duplicate daemon refuses to start.
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
(5 MB x 2 backups), and the pid file lives at
`~/.dws/connect/<key>/daemon.pid`.
- The daemon does NOT survive a reboot. For that, use Option B.
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
> Windows service wrapper around the foreground command instead.
## Option B: OS service manager (survives reboot)
Use the foreground command (NOT `--daemon`) and let the OS supervise and
restart it. This is the most robust way to get boot-time auto-start.
### macOS — launchd
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dingtalk.dws.connect</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dws</string>
<string>dev</string>
<string>connect</string>
<string>--channel</string>
<string>claudecode</string>
<string>--unified-app-id</string>
<string>REPLACE_UNIFIED_APP_ID</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/dws-connect.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/dws-connect.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
```
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
itself relies on the single-instance lock to avoid duplicates.
### Linux — systemd (user service)
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
`REPLACE_UNIFIED_APP_ID`, then:
```bash
systemctl --user daemon-reload
systemctl --user enable --now dws-connect.service
# allow it to keep running after logout:
loginctl enable-linger "$USER"
```
```ini
[Unit]
Description=DWS DingTalk robot connector
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dws dev connect \
--channel claudecode \
--unified-app-id REPLACE_UNIFIED_APP_ID
Restart=always
RestartSec=5
# Optional hardening:
# NoNewPrivileges=true
# PrivateTmp=true
[Install]
WantedBy=default.target
```
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
stderr into the journal (`journalctl --user -u dws-connect -f`).
## Legacy: passing clientId/clientSecret directly (not recommended)
If you truly must pass credentials on the command line (e.g. one-off local
debugging without a unifiedAppId), the CLI still accepts
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
security warning to stderr. This form:
- exposes `clientSecret` to every user on the box via `ps -ef`;
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
makes rotation harder;
- means `dws dev connect restart` cannot re-fetch credentials — you
must re-run the full command yourself.
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
trade-off.
## Which to choose
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
- Need it to come back after a reboot, with the OS owning the lifecycle → use
launchd / systemd with the foreground command.
-137
View File
@@ -1,137 +0,0 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
## 手工评测流程
逐条执行,每条三步:
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
## 覆盖矩阵
| 用例 | 覆盖的子命令 | 横切行为 |
|------|-------------|---------|
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
---
## 用例
### C1. 新建应用并配齐基础
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
- **期望(分步)**:
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
2. `app get --unified-app-id <id> --format json` 看详情。
3. `credentials get --unified-app-id <id> --format json` 取凭证。
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
### C2. 应用列表与按名定位
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
- **期望(分步)**:
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
### C3. 应用生命周期(停用 / 启用 / 删除)
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
- **期望(分步)**:
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
### C4. 网页应用配置到生效
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
- **覆盖**:`webapp config` / `get`;生效模型。
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
- **易错点**:配完直接说已生效,不提版本通道。
### C5. 版本发布全流程(含选审批人)
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
- **期望(分步)**:
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
2. `version list` 看历史、`version get --version-id <id>` 看详情。
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
### C6. 权限全流程(查 / 申请 / 批量取消)
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
- **期望(分步)**:
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
### C7. 成员与安全配置
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
- **期望(分步)**:
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
### C8. 机器人建号、配置与本地建联
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
- **期望(分步)**:
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
### C9. 事件订阅与上游错误排查
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
- **期望(分步)**:
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
### C10. 意图消歧(泛词边界)
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
- **覆盖**:泛词消歧、边界与角色。
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
---
## 备注
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
-321
View File
@@ -1,321 +0,0 @@
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `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 dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
## 安装后验证
先确认 `dws` 可执行:
```bash
dws version
```
确认 `dws dev app` 命令存在:
```bash
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
```bash
dws auth status
```
如果尚未登录:
```bash
dws auth login
```
登录完成后读取应用列表:
```bash
dws dev app list --format json
```
## dws dev 是什么
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
- 配置网页应用首页和管理后台地址。
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
```text
帮我查一下最近创建的开放平台应用。
```
```text
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
```
```text
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
```
```text
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
1. 先查询定位应用。
2. 先 dry-run 预览。
3. 明确展示将要修改的应用、字段和值。
4. 用户确认后加 `--yes` 执行。
5. 执行后回读验证。
## 第一个写操作
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws dev app robot result --task-id <taskId> --format json
```
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
### 应用管理
```bash
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
### 权限点管理
```bash
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人能力
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws dev app` 不存在
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws dev app --help --format json
```
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws dev app list` 失败
优先检查登录态:
```bash
dws auth status
dws auth login
```
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
- 应用开发者。
- 企业管理员或具备开放平台应用管理权限的角色。
### 机器人配置失败
先查当前机器人状态:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
and publish versions with dry-run safety built in.
```
中文版本:
```text
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
所有写操作先预览,再确认执行。
```
## 参考
- Notion Developer Platform: https://www.notion.com/product/dev
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
-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.
-269
View File
@@ -1,269 +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) |
#### 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-*` 与差分门禁)。
@@ -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
+6 -127
View File
@@ -5,72 +5,11 @@
| 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_<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 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 / 退出码
@@ -80,9 +19,8 @@ IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
@@ -91,10 +29,9 @@ With `-f json`, error responses include structured payloads: `category`, `reason
## Output Formats / 输出格式
```bash
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 contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
```
## Dry Run / 试运行
@@ -106,65 +43,7 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
## Output to File / 输出到文件
```bash
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 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
### 路径写法
```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 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_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` | 语义审核状态与来源追踪 |
`parameters.<flag>.required` 是按来源 precedence 解析后的 Agent 参数契约;`cli_required=true` 才表示 Cobra 将该 flag 标记为硬必填。条件必填或别名选择通过 `required_when` 和 `constraints.require_one_of` 表达。`required` 不直接复制 MCP input schema,也不取代 Cobra 的实际执行校验。
### 筛选输出
```bash
dws schema "calendar event create" --jq '.parameters' # 只看参数
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
dws contact user search --keyword "Alice" -o result.json
```
## Shell Completion / 自动补全
-213
View File
@@ -1,213 +0,0 @@
# 发布手册(预发 / 正式)
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
## 推荐入口:GitHub 云端发布
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
基于当时最新的 `main` 发起发布:
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
## 自动版本规则
- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。
- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。
- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。
- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。
- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。
## 全平台撤回与回滚
已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写:
- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。
- `reason`:8–300 字符的单行公开原因。
- `confirmation`:精确输入 `WITHDRAW <version>`,例如 `WITHDRAW v1.0.53`。
该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行:
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。
撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。
撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。
撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。
## 兼容入口:本地发布
安装发布 Skill 后直接运行:
```bash
dws-release
```
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
```bash
dws-release config --remote origin
```
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
## 发布模型
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 补正式 CHANGELOG;允许继续通过 PR 合入新 commit
→ vX.Y.Z(正式发布,封板提交必须包含该 beta 提交)
```
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线要求该 beta 已成功交付、未撤回,且 beta 提交必须位于正式发布封板提交的历史中——不能跳过 beta 直接发正式版,但允许在 beta 之后把经过 review 合入 `main` 的 commit 一起发布。
## 预发发布
运行统一入口:
```bash
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
beta 验证通过后,运行正式版入口:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
## CHANGELOG 契约
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
```markdown
## [1.2.3-beta.1] - 2026-07-11
### Changed
- 本次 beta 验证的用户可见变化。
```
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或
stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧
attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release
workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
## 既有 tag 的紧急恢复
云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
```bash
dws-release recover v1.2.3-beta.1
```
命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
File diff suppressed because it is too large Load Diff
-219
View File
@@ -1,219 +0,0 @@
# RFC:DWS 预制 Skill 安装、升级与模式迁移
| 字段 | 内容 |
|---|---|
| 状态 | Accepted / as implemented |
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
## 1. 背景
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
## 2. 目标与非目标
### 2.1 目标
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
- 安装预览、确认和实际执行使用同一份计划。
### 2.2 非目标
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
- 不持久化用户对预制 Skill 的本地删除或排除意图。
- 不提供跨所有 Agent 目标的事务式回滚。
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
## 3. 业内调研
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
| 观察 | 对 DWS 的启示 |
|---|---|
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
## 4. 布局合同
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
不可恢复的删除。
## 5. 官方集合与升级策略
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
- 本地删除的预制 Skill 会在下一次升级恢复;
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
- 新版本新增的官方 Skill 会自动安装;
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
- `dingtalk-shared` 始终随官方集合安装。
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
provenance,供安全清理、诊断与后续迁移使用。
## 6. 目录所有权
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
`~/.dws/skills-state.json` 中写入:
```json
{
"version": "v0.2.14",
"official_skills": ["dingtalk-aitable"],
"updated_skills": ["dingtalk-aitable"],
"managed_skills": [
{
"name": "dingtalk-aitable",
"version": "v0.2.14",
"source": "dws-upgrade",
"digest": "sha256:<64 个十六进制字符>",
"digest_scope": "skill-directory-v1"
}
],
"updated_at": "2026-08-11T12:34:56Z"
}
```
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
覆盖恢复。
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
1. Skill 名称存在于统一状态的 `managed_skills` 中;
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
### 6.1 对 Agent 的影响
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
不会成为提示词上下文或影响 Agent 行为。
## 7. Setup:Plan → Confirm → Execute
`dws skill setup` 分为三个阶段:
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
3. **Execute**:确认后严格执行计划中的备份和安装。
安全要求:
- 非交互环境未传 `--yes` 时拒绝执行;
- 用户拒绝确认时必须零文件写入;
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
- 同一目标先完成所有必要备份,再复制新集合;
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
## 10. 缓存与制品
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
setup 在未显式指定 `--source` 时的本地回退缓存。
缓存刷新必须采用同级 staging + publish:
1. 在 staging 中完整复制并验证新树;
2. 发布前保留旧缓存;
3. 通过 rename 发布新缓存;
4. 复制或发布失败时保留或恢复旧缓存;
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
## 11. 安装入口一致性
以下入口都遵守本 RFC:
| 入口 | 默认模式 | 失败合同 |
|---|---|---|
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
## 12. 验收与回归门禁
合入和后续修改至少覆盖:
- mono → multi、multi → mono 互斥切换;
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
- 统一状态中登记的过期官方 Skill 被备份并移除;
- 备份、复制、统一状态写入、缓存 publish 故障注入;
- 非交互确认拒绝与显式 `--yes`;
- 部分失败返回非零且不写错误状态快照;
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
-150
View File
@@ -1,150 +0,0 @@
# 钉钉 AI 群机器人快速上手
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
### macOS / Linux
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```bash
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
```bash
dws auth login
```
按提示扫码登录即可。
## 第二步:创建机器人
建号是异步的,两步(名字、描述可以改成你自己的):
```bash
# 1) 提交建号任务,记下返回的 taskId
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
dws dev app robot result --task-id 上一步返回的taskId --format json
```
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
## 第三步:把机器人接上你本地的 AI
```bash
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
```
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
## 第四步:拉进群聊
在钉钉里打开目标群:
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
## 进阶配置(可选)
按需加在第三步的命令后面:
| 参数 | 作用 |
|------|------|
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
| `--allowed-groups 群ID` | 群白名单 |
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
```bash
dws dev connect \
--agent-cmd "你的AI命令 一次性问答参数" \
--unified-app-id 你的unifiedAppId
```
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
## 常见问题
**执行命令报 `zsh: parse error near '\n'`?**
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
**群里 @机器人 没反应?**
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
**第二步提示"当前用户没有开发者身份"?**
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
**提示找不到 dws 命令?**
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
**提示本地没有装 AI 工具?**
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
**机器人回复"调用失败"?**
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
这通常不是模型问题,而是"机器人看到的上下文比终端少":
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
## 会话指令:`/new` 和 `/clear`
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
| 指令 | 作用 |
|------|------|
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | **清空当前会话**:彻底从头开始 |
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
-322
View File
@@ -1,322 +0,0 @@
# DWS Agent Schema 统一方案
## 1. 核心定义
DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描述真实 Cobra 命令,并补充 Agent 选择、参数映射、组合约束、安全确认和接口事实。
设计遵循三条硬规则:
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、ProductDecl / leaf `Contract`、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。interface 事实由 leaf `Contract` / `ParamDecl` 声明(`schema_mcp_metadata` 已退役),**不得**从 MCP meta 生成 CLI flag(见 §4.1 同源决策)。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
3. **Collector-first,Catalog 只出不进。** identity collector(`CollectIdentitySpecs`,遍历携带 `ContractFinal.Identity` 的 live Cobra leaves)是稳定 command identity/navigation 的唯一事实源;reviewed `schema_command_registry/` 已退役,identity 由声明(Contract)即代码提供,不再有独立的 reviewed identity 文件。production 通过 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 组装 `SchemaRegistry`,并从它投影 ToolSpec wire 与 `ResolveMeta`。`cmd_schema_catalog` 只能生成 CI/local dump,`internal/cli/schema_catalog/`、`schema_meta_index.gob` 和 `schema_meta_index.json` 不得提交或成为运行时来源。`schema_agent_metadata/` / `schema_hints/` / `schema_command_registry/` 已退役;若存在则 policy 失败。生产 Agent selection / safety / interface 权威为 leaf `ContractFinal` 与 `ProductDecl`;`agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 仅作 `cmd_schema_catalog` CI/local dump 辅助,不得作为生产权威。
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
**flag / help / schema 参数面同源**(已决策):Contract / LeafSpec 为 CLI 表面权威;分层字段归属与门禁 ID 见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
## 2. 单向数据流
```text
identity collector (CollectIdentitySpecs)
walks live Cobra leaves carrying ContractFinal.Identity;
reviewed exclusions applied; single identity source
(reviewed schema_command_registry/ retired)
|
v
EffectiveCommandRegistry
|
v
exact binder to live Cobra tree
+ native identity consistency assertions
|
v
BoundCommandRegistry
|
v
live Cobra flag facts / typed parameter metadata
+ leaf Contract (Safety / ContractDecl / ParamDecl → contract_final)
+ ProductDecl (product routing prose; production Agent authority)
+ schema_parameter_mapping_ledger.go (reviewed mapping exclusions / removals)
+ leaf Contract.Interface / ParamDecl (declared interface facts)
+ skills/mono Markdown (evidence only; not concatenated)
|
v
source adapters + resolvers
|
v
one typed SchemaRegistry
(one ToolSpec per command)
+
typed SchemaIndex
+-----------+-----------+
| |
v v
build-time typed gates RegisterSchemaSourceRoot
-> ResolveSchemaBuild
(runtime assembly; lazy Once)
|
v
SchemaRegistry + SchemaIndex
+ ResolveMeta projection cache
(in-process map; not gob/json fixture)
|
+---------------------+------------------+
| | |
overview/product/group leaf --all
projections projection full projection
| | |
+---------------------+------------------+
|
v
runtime query + delivery gates
(cmd_schema_catalog = CI/local dump only;
InstallBuildTimeAgentMetadataJSON = dump helper,
not production Agent authority)
```
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、已退役 hint overlay 或生成 JSON 重新解析 command identity。
## 3. 与 Lark 的关系
DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
| Lark 分层 | DWS 对应层 |
|---|---|
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
| API Commands ← 平台 OAPI meta | **不**作为 DWS 主路径;可选 1:1 MCP 透传子集见同源文档 §5 |
| Shortcuts ← 手写声明 + Execute | LeafSpec / Shortcut + Contract 表面(路径 A) |
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 identity collector 从携带 `ContractFinal.Identity` 的 live Cobra leaves 收集 identity,绑定前生成唯一的 `EffectiveCommandRegistry`(reviewed `schema_command_registry/` 已退役),因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。飞书也是**分层单权威**(API 用平台 meta,Shortcut 用手写契约),不是全家只有 meta——DWS 对齐的是这一分层,而不是「用 MCP meta 生成全部 CLI」。
DWS 内部 resolved model 为:
```text
SchemaRegistry
-> []ProductSpec
-> []ToolSpec
-> ToolIdentitySpec
-> []ParameterSpec
-> RuntimeSchemaConstraints + []RuntimeSchemaPositional
-> SafetySpec
-> InterfaceSpec
-> SelectionSpec
-> map[field]FieldProvenance
```
字段合并和 precedence 在进入该模型前完成。`map[string]any`/flat JSON 只允许存在于 renderer 和 snapshot/wire boundary,不能作为内部 resolver、navigation 或 gate 的第二套数据模型。
DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和选择字段也保持现有键名。架构对齐不等于未版本化地切换到 Lark `inputSchema/outputSchema/_meta` envelope;若未来提供该格式,应作为明确版本的新投影,并保留现有兼容输出。
## 4. 来源职责
| 来源 | 负责内容 | 明确不负责 |
|---|---|---|
| Contract / LeafSpec / `corecmd.Spec` | **CLI 表面权威**:flags、defaults、required、enum、关系约束、运行时 Risk;编译为 cobra 与 help | canonical identity、selection 文案、虚构 RPC |
| identity collector(`CollectIdentitySpecs`) | 从 live Cobra leaves 的 `ContractFinal.Identity` 声明收集稳定 canonical identity、primary CLI path、alias、exposure 和导航(reviewed `schema_command_registry/` 已退役) | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
| reviewed exclusions(`ReviewedRuntimeSchemaExclusions`) | exact、reviewed、带 reason 地将指定 public runnable leaf 排除出 effective 表面 | 运行时 fallback、prefix/wildcard 排除、创建命令 |
| Go/Cobra | Contract 编译后的可执行投影:路径是否真实可执行、Cobra 接受的 flag、DefValue、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC;**不得**成为与 Contract 平行的第二套 flag 权威 |
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
| typed parameter metadata / constraints | 由 Contract 约束投影而来的 `require_one_of` / 互斥等;以及仍需 reviewed 的 `required_when` 等 | 命令 identity |
| `schema_parameter_mapping_ledger.go` | CLI flag 无直接 RPC property 的 exclusions / removals | 命令发现、risk 推断、创建 CLI flag;property 交付归 ParamDecl.Property |
| leaf `Contract.Interface` / `ParamDecl` | 声明的 RPC identity 与 interface_* 事实(`schema_mcp_metadata.json` 已退役) | CLI identity、运行时路由、**创建 CLI flag**、risk 推断 |
| ProductDecl + leaf `Contract.Selection` | reviewed selection / product routing prose(`contract_final`) | 创建 Cobra 命令或参数、改写 safety;`schema_hints/` 已退役 |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `cmd_schema_catalog` CI/local dump(可选 `schema_catalog/` / meta-index) | resolved registry 的兼容序列化快照,仅供 jq/determinism;不得提交为 runtime 来源 | production delivery、`ResolveMeta` 权威、identity fallback、手工修复源 |
identity collector 从 live Cobra leaves 的 `ContractFinal.Identity` 声明生成 `CommandSpec`,应用 reviewed exclusions 后按确定性规则索引为 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。reviewed `schema_command_registry/` 已退役,其历史角色(稳定 canonical identity/navigation 事实源)由 collector 承接。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
### 4.1 flag / help / schema 同源(路径 A + 嵌入)
完整决策、字段归属表、`HOM-*` 门禁规划与可选 MCP 透传准入条件见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
摘要:
- **同源面**:Contract → cobra flags ≡ `--help` Flags ≡ **嵌入注解后的** schema `parameters` / 关系约束;显式 `Risk` 经 `dws.schema.risk` overlay 进 Schema Safety。
- **嵌入点**:`command.embedContractIntoSchema` 写入 `dws.schema.contract` / property / type / required;`AnnotateConstraints` 写入 constraints;Schema 组装(`runtimeToolSpecFromMetadata` / `ResolveSchemaBuild`)消费这些注解进入 typed `SchemaRegistry`(runtime assembly;非 `go:embed` catalog 交付)。
- **硬规则**:CLI 表面事实 = **声明(Contract 数据字段)OR 人工标注**;禁止纯推断。非 CLI 表面字段(identity / selection / interface)必须有**评审源**。声明写法见同源文档 §1.2;标注见 §1.3;**`ToolSpec` 全字段权威见 RFC §5.0.4 / 同源 §1.4**。
- **非同源面(有意)**:identity(collector)、selection 文案(ProductDecl / leaf `Contract.Selection`)、RPC 形状(MCP meta 仅 `interface_*`)、dry-run 正能力 registry。
- **禁止**:以 MCP meta 为主通道生成 Leaf/Shortcut 的 flag;已退役的 hint overlay 改写 type/required/default;Schema 字段无权威归属。
## 5. 统一解析与 precedence
### 5.1 Identity
- identity collector(`CollectIdentitySpecs`)是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源:每个携带 `ContractFinal.Identity` 的 runnable Cobra leaf(含 Hidden deprecated/migration shims)贡献一条 identity,reviewed exclusions 精确应用;reviewed `schema_command_registry/` 已退役,其历史角色由 collector 承接。
- Collector 输出的 `CommandSpec` 在索引时 fail-closed 校验 canonical/product/path/alias/visibility 的合法性与唯一性;重复 identity、alias 复用 primary path 或 alias collision 全部失败,不能按 precedence 静默覆盖。
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
- Identity 不做名称推断,不从 Catalog/generated metadata fallback,也没有多来源 winner。
删除 native materialization 前已做写入审计:旧
`ApplyNativeRuntimeSchemaContracts` 的唯一写操作是对已存在命令调用
`AttachRuntimeSchema`,只写 command identity 的 product/tool/source annotation;
它不写 flag property/type/required、constraints、positionals、title/description
或 interface mapping。这些字段原本已分别由 parameter binding/metadata、
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
#### Identity 输入审计(registry 已退役)
历史上 identity 来自 reviewed `schema_command_registry/`(`registry.json` +
`products/*.json`,由随附 JSON Schema 校验)。该 reviewed registry 已退役,
改由 identity collector 承接:identity 现在由 `CollectIdentitySpecs` 从 live
Cobra 树的 `ContractFinal.Identity` 声明收集,输入契约即声明本身。严格 Go
索引(`indexCommandSpecs`)继续 fail-closed 校验:
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
- `aliases` 唯一且不能复用 primary path;
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
`public`;
- primary path、alias、canonical 和 product 之间的交叉唯一性约束。
Registry semantic hash 仍覆盖 canonical、primary CLI path、alias 集合、
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
不使用当前命令数量作为常量。
普通 `go generate ./internal/cli` 只生成
`param_aliases_generated.go`;production Catalog / `ResolveMeta` 由 runtime
`ResolveSchemaBuild` 装配(`deliverySchemaCatalog` Once 后缓存 Meta 投影)。
`cmd_schema_catalog` 仅按需打 CI/local dump;其 `InstallBuildTimeAgentMetadataJSON`
inject 仅服务 dump,生产 Agent 权威仍是 leaf `ContractFinal` / `ProductDecl`。
不写也不 embed `schema_agent_metadata/`。drift policy 禁止已退役的
`schema_command_registry/` 在生成前后重新出现;原独立脚本
`check-schema-command-registry.sh` 的 registry-agnostic side guards(禁用旧
native materialization 符号、go:generate 单轨、lazy loader 纪律)已迁入
`check-schema-catalog.sh`。
### 5.2 Parameter
每个字段按明确的来源 precedence 选择一次,并把 winner、候选值和来源写入 provenance。precedence **与值无关**:不能因为 `required=true` 看起来更严格就让它越级获胜。更高优先级的 reviewed manual override 可以把 `required`、映射、interface type 或描述调高,也可以调低。
实现中的参数字段顺序固定为:
```text
versioned binding > command constraint > typed metadata
> native/Cobra contract (ParamDecl / ContractFinal)
> MCP metadata > inference/default
```
命令 `title` / `description` 使用独立但同样确定的文本顺序:
```text
description: Cobra Long > ContractDecl description (contract_final) > MCP metadata > inference
(Long 胜出时 provenance = cobra_help / cobra_help_preferred)
title: ContractDecl / ContractFinal > Cobra Short > MCP metadata > inference
```
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
provenance candidate 保留;参数级 RPC 文案可进入 `interface_description`,
但不得覆盖 leaf 自己的标题和执行语义。
Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/provenance 保留;它不应在 renderer 中再次静默改写已经解析的 Agent projection。
### 5.3 Safety、selection 与 interface
`effect`、`risk`、`confirmation`、`idempotency`、selection 和 interface disposition 同样按 source precedence 解析,而不是按值的“严格程度”合并。更高优先级的 reviewed explicit/manual source 可以升高或降低最终值;同 precedence 的不同值必须报冲突。
最终 interface disposition 还必须满足 conflict matrix:
- `mode` 与 `availability` 正交:`mode` 只允许 `mcp | local | composite`,`availability` 只允许 `available | unavailable`;`unavailable` 不是第四种 mode。
- `mcp + available`:只表示命令可由一个 pinned、参数可映射且语义等价的 `interface_ref` 完整表达;本地 wrapper 只是固定默认值或投影返回值时,也必须先证明参数和执行语义没有漂移。
- `local + available`:仅用于纯本地进程、静态数据或策略操作,不得携带 direct `interface_ref`;“远端 RPC 尚未进入 pinned metadata”不能归类为 local。
- `composite + available`:用于多 RPC、条件路由、本地投影,或 reviewed unpinned remote adapter;不得用单个 `interface_ref` 冒充完整实现,且必须提供 reviewed reason。未来需要表达多个 RPC 时使用单独的复合接口模型。
- 任意合法 mode + `unavailable`:不得携带 `interface_ref`,必须提供明确 reason,并且 Agent 不得把它当作可用接口。
## 6. Schema、Help 与业务数据边界
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
- 执行参数以 Cobra 实际接受的 flags 为准;不要发送 Help 中不存在的 flag。
- 安全语义冲突时不要采用更宽松值。先按更保守的解释确认;如果无法确定安全执行方式,停止并报告漂移。
- Schema/Help 只完成命令发现和契约读取。需要业务结果时,必须继续执行真实 read/search/list 命令。
上述运行时漂移策略不改变构建期的 value-neutral precedence;前者是在契约已经互相矛盾时保护用户,后者是在确定性生成同一契约。
## 7. 查询投影
```bash
dws schema # 产品紧凑概览
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 所有工具的完整 leaf 导出
```
`schema list` 是根概览的兼容入口。
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
## 8. 生成与发布
当 Cobra、flag、identity、binding、leaf `Contract` / ProductDecl 或 Skill 发生变化时:
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 现在通过 leaf Contract 的 `ContractFinal.Identity` 声明完成(identity collector 据此收集;reviewed `schema_command_registry/` 已退役)。参数、Skill 或 metadata 单独变化时不要机械改写 identity 声明,也不要从旧 Catalog 反向生成它。
2. 不应进入稳定 Agent 契约的 public runnable leaf 使用 reviewed exclusions(exact path、带 reason)。Native identity annotation 若存在,应作为与 identity 声明一致的实现断言维护,而不是用来 materialize identity。
3. 生成参数别名并验证运行时 Schema 组装。`go generate ./internal/cli` 只运行 `cmd_param_aliases`;`cmd_schema_catalog` 仅按需生成 CI/local dump。生产权威为 leaf `ContractFinal` / `ProductDecl`;CI dump 可经 `agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 在内存中注入 Agent metadata,不写 `schema_agent_metadata/`:
```bash
make generate-schema
go generate ./internal/cli
# 可选:生成 artifacts/ 下的 CI/local dump
make generate-schema-catalog
```
`cmd_schema_agent_metadata` 可保留为非交付工具/测试,但不是 `go:generate` 入口,也不应再作为发布步骤。
生成文件只有 `internal/cli/param_aliases_generated.go`(参数别名生成物)。`cmd_schema_catalog` 的 Catalog 和 meta-index 是可选 CI/local dump,不是交付物,且不得写入或提交到 `internal/cli/`。
`schema_agent_metadata/`、`schema_agent_metadata_audit.json` 与 `schema_hints/` 已退役;若存在则 policy 失败。只编辑来源;不要手工编辑或提交 Catalog / meta-index dump。
## 9. Completeness 与 final-delivery invariant
门禁必须验证最终交付对象,而不是某个中间层或数量:
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex` 与 Catalog canonical sets 必须精确一致(含组装时内存 inject 的 Agent metadata 语义),不能只比较 count。
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
- **同源门禁(规划,见同源文档 §4)**:受管命令逐步满足 `HOM-P1`–`P3`(parameters ≡ cobra/Contract)、`HOM-S1`–`S3`(Safety/Risk 对齐)、`HOM-I1`(interface 不创建 flag)、`HOM-D1`(help ≡ schema parameters)。hints 不得作为 type/required/default 的 winner。
推荐本地验证:
```bash
make generate-schema
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-schema-catalog.sh
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
```
## 10. 明确禁止
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 重新引入 `schema_agent_metadata/`(或 audit JSON)作为交付物、`go:embed` 目标,或把它写回 `go:generate` 入口。
- 从 MCP meta / 已退役的 `schema_mcp_metadata` **生成或补齐** LeafSpec/Shortcut 主路径的 CLI flag(interface overlay 除外);未满足同源文档 §5 准入条件时启用「MCP 透传生成通道」。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
- 让 ProductDecl / leaf `Contract`、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
- 重新引入 `schema_hints/`(含 selection/metadata/imported/audit JSON)或任何 HintFile overlay,并在与 Contract/cobra 冲突时赢得 type/required/default。
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
- 将 LeafSpec、`+shortcut` 或 write-guard/cursor/多步命令注册为 `mcp_passthrough` 表面。
-174
View File
@@ -1,174 +0,0 @@
# Shortcut 真实测试:后端 / MCP 问题整理
这份报告只汇总 `failure_category = backend-or-mcp-error` 的 case,已尽量排除权限、缺真实资源、当前账号无数据等噪音。
## 总览
- Backend/MCP case 总数:33
- 聚合问题数:8
- 复现口径:真实 dws CLI;无 mock;无 dry-run;命令输入和 trace_id 均来自真实测试结果。
## 建议优先看
1. [P1] Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失(15 case)
2. [P1] Chat card 发送 receiverUid 疑似未从 receiver 透传(1 case)
3. [P1] Chat 入群审批 applicantUid/inviterUid 疑似未透传(1 case)
4. [P1] AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error(5 case)
5. [P1] AI 表格 Workflow 查询在真实 Base 下返回系统级错误(2 case)
6. [P1] AI 表格 roleId 参数疑似未被 MCP 正确读取(3 case)
7. [P2] AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR(2 case)
8. [P2] AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR(4 case)
## Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失
- 优先级:P1
- 建议 owner:IM MCP / IM 后端字段映射
- 现象:CLI 已传 group/conversation-id/open-conversation-id(部分 case 使用真实 cid),后端仍报 openCid/openConversationId/cid required。
- 期望:MCP schema/网关应接受并透传 openConversationId/openCid/cid 中的兼容字段;如果资源无效,应返回“无效会话”,而不是 required。
- 涉及 case:15
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `chat +chat-members-get` | `tools/call` | `2127d89817840997754345760e07bd` | [UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json` |
| `read` | `chat +chat-messages` | `tools/call` | `2104a64c17840997767792656e085e` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json` |
| `read` | `chat +messages-list` | `tools/call` | `2127d89817840997797873841e0757` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json` |
| `write` | `chat +chat-mute-member` | `tools/call` | `2104a64c17840999166036583e08a3` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json` |
| `write` | `chat +chat-transfer-owner` | `tools/call` | `0b5deb3217840999222318863e087a` | [UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json` |
| `write` | `chat +conversation-clear-messages` | `tools/call` | `2127d89817840999254816721e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-clear-red-point` | `tools/call` | `2104a64c17840999265511295e085f` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-hide` | `tools/call` | `2127d89817840999276117140e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-mark-unread` | `tools/call` | `0bb7c36217840999298744910e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-mute` | `tools/call` | `2104a64c17840999309241865e085f` | [UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-mute-at-all` | `tools/call` | `2127d89817840999320028068e07dd` | [UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-mute-red-envelope` | `tools/call` | `0bb7c36217840999330228283e07fe` | [UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-set-top` | `tools/call` | `2127d89817840999341302961e07fe` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +messages-set-pin` | `tools/call` | `0bb7c36217840999521117991e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `chat +messages-unset-pin` | `tools/call` | `2104a64c17840999544428103e08ee` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
## Chat card 发送 receiverUid 疑似未从 receiver 透传
- 优先级:P1
- 建议 owner:IM MCP / card 发送参数映射
- 现象:CLI 传入 receiver=103262,后端仍报 receiverUid 和 openConversationId 不能同时为空。
- 期望:receiver 应映射为 receiverUid,或 schema 明确要求 receiverUid;真实入参不应在 MCP 层丢失。
- 涉及 case:1
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `chat +messages-send-card` | `tools/call` | `2104a64c17840999509255753e081a` | [UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json` |
## Chat 入群审批 applicantUid/inviterUid 疑似未透传
- 优先级:P1
- 建议 owner:IM MCP / 入群审批参数映射
- 现象:CLI 传入 applicant=103262、inviter=519019,后端仍报 applicantUid required。
- 期望:applicant/inviter 应映射为 applicantUid/inviterUid;如果 recordId/group 无效,应返回对应资源错误而不是 applicantUid 缺失。
- 涉及 case:1
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `chat +chat-audit-join` | `tools/call` | `0bb7c36217840999154832733e0758` | [UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json` |
## AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error
- 优先级:P1
- 建议 owner:AI 表格 MCP wrapper
- 现象:多条 AI 表格命令返回 MCP_TOOL_ERROR,内部 JSON 同时出现 success=true、status=error、error 非空。
- 期望:只要 error 非空或 status=error,success 应为 false,外层也应按业务错误返回稳定错误码/trace。
- 涉及 case:5
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +export-data` | `-` | `2104a64c17840997514714448e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"m… |
| | input | | | `/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes` |
| `write` | `aitable +chart-update` | `-` | `2106d98117840998553244877e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json` |
| `write` | `aitable +record-update` | `-` | `0bab027317840998747383236e090b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"stat… |
| | input | | | `/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json` |
| `write` | `aitable +record-upsert` | `-` | `2106d98117840998759832182e087b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"e… |
| | input | | | `/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json` |
| `write` | `aitable +view-set-fill-color-rule` | `-` | `2106d98117840998924181709e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success… |
| | input | | | `/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json` |
## AI 表格 Workflow 查询在真实 Base 下返回系统级错误
- 优先级:P1
- 建议 owner:AI 表格 Workflow MCP / 后端
- 现象:使用真实可访问 Base 查询 workflow list/get,返回 LIST_WORKFLOWS_ERROR/GET_WORKFLOW_ERROR。
- 期望:无 workflow 时应返回空列表或 WORKFLOW_NOT_FOUND;有后端异常时需提供稳定错误码和可排查 trace。
- 涉及 case:2
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +workflow-get` | `-` | `2104a64c17840997556363676e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json` |
| `read` | `aitable +workflow-list` | `-` | `2127d89817840997572427879e075d` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary… |
| | input | | | `/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json` |
## AI 表格 roleId 参数疑似未被 MCP 正确读取
- 优先级:P1
- 建议 owner:AI 表格 MCP role 接口
- 现象:CLI 已传 --role-id,但 MCP 返回 roleId is required。
- 期望:role-id/roleId 字段应被正确映射;如果 role 不存在,返回 ROLE_NOT_FOUND,而不是 required。
- 涉及 case:3
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +role-get` | `-` | `2104a64c17840997542904838e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json` |
| `write` | `aitable +role-delete` | `-` | `2106d98117840998782482701e089c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +role-update` | `-` | `2132f5ca17840998794483634e08d8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json` |
## AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR
- 优先级:P2
- 建议 owner:AI 表格 primary doc MCP / 后端
- 现象:record-query 已能查到真实 recordId,但 primary-doc 查询返回 no record、type=SYSTEM_ERROR。
- 期望:若该记录无主文档,应返回空/未创建;若 recordId 语义不匹配,应返回明确参数错误,不应是系统错误。
- 涉及 case:2
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +base-get-primary-doc-id` | `-` | `0b5deb3217840997466255627e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
| | input | | | `/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
| `read` | `aitable +record-primary-doc-get` | `-` | `2127d89817840997528393730e079c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
| | input | | | `/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
## AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR
- 优先级:P2
- 建议 owner:AI 表格 MCP wrapper / 后端错误码
- 现象:安全负向 ID 下,部分写接口返回 getDentryDTO returns null、type=SYSTEM_ERROR、retryable=true。
- 期望:资源不存在应返回 INPUT_ERROR/NOT_FOUND 且 retryable=false,避免误导调用方重试。
- 涉及 case:4
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `aitable +field-delete` | `-` | `0bab027317840998611338768e08c8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +field-update` | `-` | `213ee25c17840998623207342e08e2` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json` |
| `write` | `aitable +record-delete` | `-` | `2132f5ca17840998724753149e0853` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +table-update` | `-` | `213ee25c17840998877246229e087f` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json` |
-279
View File
@@ -1,279 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shortcut 真实测试失败逐项 review</title>
<style>
:root{--bg:#0f1420;--card:#151d2b;--line:#263246;--text:#dce7f7;--muted:#91a0b5;--blue:#8fd3ff;--green:#66d38a;--yellow:#e2b23c;--red:#f27272;--purple:#d3a7ff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif}
header{padding:28px 32px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#172033,#0f1420)}
h1{margin:0 0 8px;font-size:26px}
h2{margin:28px 0 10px;font-size:18px}
.sub,.note,.count{color:var(--muted)}
.wrap{padding:18px 32px 40px;max-width:1800px;margin:0 auto}
.note{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:10px 0 18px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:18px 0}
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
.stat .n{font-size:24px;color:var(--blue);font-weight:700}
.stat .l{color:var(--muted);font-size:12px}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin:16px 0 22px}
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
th{background:#1b2536;color:var(--muted);font-size:12px;font-weight:600;position:sticky;top:0;z-index:1}
tr:last-child td{border-bottom:none}
code{font-family:"SF Mono",Menlo,Consolas,monospace;color:#c7cfdb;font-size:12px}
.review{table-layout:fixed}
.review th:nth-child(1),.review td:nth-child(1){width:44px}
.review th:nth-child(2),.review td:nth-child(2){width:165px}
.review th:nth-child(3),.review td:nth-child(3){width:70px}
.review th:nth-child(4),.review td:nth-child(4){width:120px}
.review th:nth-child(5),.review td:nth-child(5){width:130px}
.review th:nth-child(8),.review td:nth-child(8){width:130px}
.review th:nth-child(9),.review td:nth-child(9){width:180px}
.review td{word-break:break-word}
.num{color:var(--muted);text-align:right}
.risk{color:var(--green)}
.cat{color:var(--yellow)}
.owner{color:var(--purple)}
.evidence{color:#c7cfdb;font-size:12px}
a{color:var(--blue)}
</style>
</head>
<body>
<header>
<h1>Shortcut 真实测试失败逐项 review</h1>
<div class="sub">由 <code>scripts/gen_shortcut_error_review.py</code> 从真实测试结果生成;目标是把每个失败项落到“应该改哪里”。</div>
</header>
<main class="wrap">
<div class="note">
Read:204 条,成功 162,失败 41,超时 0。
Write:162 条,成功 48,失败 114,超时 0。
判定口径:如果 fake MCP 已看到字段但真实后端仍报 required,按后端/MCP schema 映射处理;如果真实后端报资源无效/不存在,按 fixture 处理;权限类不在 CLI 中绕过。
</div>
<div class="stats"><div class="stat"><div class="n">41</div><div class="l">Read 失败</div></div>
<div class="stat"><div class="n">114</div><div class="l">Write 失败</div></div>
<div class="stat"><div class="n">156</div><div class="l">逐项 review</div></div>
<div class="stat"><div class="n">72</div><div class="l">测试数据/fixture</div></div>
<div class="stat"><div class="n">22</div><div class="l">权限/应用配置</div></div>
<div class="stat"><div class="n">33</div><div class="l">后端/MCP schema</div></div></div>
<div class="summary-grid">
<section><h2>按错误类型</h2><table><thead><tr><th>类型</th><th>数量</th></tr></thead><tbody><tr><td>输入/业务校验</td><td>36</td></tr>
<tr><td>后端/MCP</td><td>33</td></tr>
<tr><td>缺 AI 表格 fixture</td><td>31</td></tr>
<tr><td>缺真实资源</td><td>30</td></tr>
<tr><td>鉴权/权限</td><td>22</td></tr>
<tr><td>缺妙记 fixture</td><td>3</td></tr>
<tr><td>敏感/高风险暂缓</td><td>1</td></tr></tbody></table></section>
<section><h2>按要改哪里</h2><table><thead><tr><th>要改哪里</th><th>数量</th></tr></thead><tbody><tr><td>测试数据</td><td>72</td></tr>
<tr><td>后端/MCP schema</td><td>33</td></tr>
<tr><td>权限/应用配置</td><td>22</td></tr>
<tr><td>测试输入/业务校验</td><td>13</td></tr>
<tr><td>后端业务/测试 fixture</td><td>11</td></tr>
<tr><td>测试输入</td><td>2</td></tr>
<tr><td>人工安全确认</td><td>1</td></tr>
<tr><td>测试输入/shortcut 枚举</td><td>1</td></tr>
<tr><td>测试输入/业务规则</td><td>1</td></tr></tbody></table></section>
</div>
<h2>缺真实资源复盘 <span class="count">· 可自造/可查资源处理结果</span></h2>
<table>
<thead><tr><th>命令/范围</th><th>处理状态</th><th>本次实际排查/造数结果</th><th>后续建议</th></tr></thead>
<tbody><tr><td><code>chat +group-members</code></td><td><span class="cat">已补齐</span></td><td>查到真实群名 `浅曦-kida,Dennis,秋画`,runner 已改为用群名而不是 openConversationId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-mget</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊消息 `msgEuOor1PmFBNlx9M06N9z1Q==`;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-read-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊会话 `cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=` 与同一 openMessageId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-query-send-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实发送返回的 openTaskId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>ding +receiver-status</code></td><td><span class="cat">已补齐</span></td><td>先只读 `ding +list` 找到已有 openDingId,再查询 receiver status;没有新发 DING,真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>sheet +list-sheets</code></td><td><span class="cat">已自造</span></td><td>创建临时在线表格 `DWS shortcut 真实测试表格 20260715`,nodeId=`mweZ92PV6O36dZbnsMZx70ylJxEKBD6p`;真实回归成功。</td><td>后续可保留为稳定 fixture,或测试结束后人工清理。</td></tr>
<tr><td><code>todo +todo-done</code></td><td><span class="cat">已自造并修复 CLI</span></td><td>runner 会先创建当前账号自己的临时待办,再执行 `todo +todo-done`;同时修复了 todo 列表 pageSize=50 返回空、响应多层 result unwrap 不稳的问题;真实回归成功。</td><td>无需后续动作;代码已有单测覆盖 nested result。</td></tr>
<tr><td><code>contact +by-mobile</code></td><td><span class="cat">已按用户授权补齐</span></td><td>使用用户指定手机号 `13161187007` 作为真实 fixture;runner 只在该命令上替换 mobile,不扩散到其它服务。</td><td>真实回归成功后该项将从失败列表移除;若后续要脱敏公开报告,可再加展示层脱敏。</td></tr>
<tr><td><code>attendance +get-class / +get-group / +get-group-filtered</code></td><td><span class="cat">不建议自造</span></td><td>`attendance +search-class` 与 `+search-group --type FIXED` 均返回空;创建班次/考勤组会改组织考勤配置,属于高影响业务数据。</td><td>需要考勤后端/业务同学提供可读测试班次与考勤组 ID。</td></tr>
<tr><td><code>chat +chat-get-by-id</code></td><td><span class="cat">暂未找到</span></td><td>该 shortcut 只接受数字 groupId;真实群列表只返回 openConversationId,没有数字群号字段。</td><td>需要 IM 后端提供可用数字 groupId,或评估是否新增 openConversationId 形态的 shortcut。</td></tr>
<tr><td><code>chat +messages-resource-url</code></td><td><span class="cat">暂未自造</span></td><td>需要真实含 mediaId 的图片/文件/视频消息;当前文本消息无法产生 resource-id。</td><td>可在测试群发一条图片/文件消息并提取 mediaId 后补 fixture;注意会产生群消息。</td></tr>
<tr><td><code>chat +thread-replies</code></td><td><span class="cat">暂未自造</span></td><td>需要真实话题消息 topicId;普通群消息不能替代。</td><td>需要话题群 fixture,或由 IM 同学提供当前账号可访问 topicId。</td></tr>
<tr><td><code>aitable +dashboard-share-get</code></td><td><span class="cat">真实资源仍失败</span></td><td>已有真实 base/dashboard,但 share-get 返回 404 `Failed to get dashboard share config`;更像分享配置未开启或后端接口行为问题。</td><td>需要 AI 表格/后端确认如何创建/开启 dashboard share fixture,或修正 404 语义。</td></tr>
<tr><td><code>write 类 delete/recall/approve/wiki move/copy 等</code></td><td><span class="cat">不自动自造</span></td><td>这些命令即便能造资源,也会涉及删除、撤回、审批通过、知识库移动/复制等高影响动作。</td><td>需要逐项授权和专门测试空间/机器人/审批单据,不建议混在批量回归里自动跑。</td></tr></tbody>
</table>
<h2>Read 失败逐项 <span class="count">· 42 条</span></h2>
<table class="review">
<thead><tr>
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
</tr></thead>
<tbody>
<tr><td class="num">1</td><td><code>aitable +base-get-primary-doc-id</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;-1&quot;,&quot;message&quot;:&quot;no record&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
<tr><td class="num">2</td><td><code>aitable +chart-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +chart-share-get`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;403&quot;,&quot;message&quot;:&quot;Forbidden&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;AUTH_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get chart share config for chart widget-dlxFo…</td></tr>
<tr><td class="num">3</td><td><code>aitable +dashboard-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +dashboard-share-get`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;Not Found&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get dashboard share config for dashboard KY9…</td></tr>
<tr><td class="num">4</td><td><code>aitable +export-data</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;taskId cannot be combined with scope, format, tableId or viewId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,…</td></tr>
<tr><td class="num">5</td><td><code>aitable +record-primary-doc-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;-1&quot;,&quot;message&quot;:&quot;no record&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
<tr><td class="num">6</td><td><code>aitable +role-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get role because roleId …</td></tr>
<tr><td class="num">7</td><td><code>aitable +workflow-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;GET_WORKFLOW_ERROR&quot;,&quot;message&quot;:&quot;调用远程服务业务异常&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get workflow in base &#x27;gpG2Nd…</td></tr>
<tr><td class="num">8</td><td><code>aitable +workflow-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;LIST_WORKFLOWS_ERROR&quot;,&quot;message&quot;:&quot;biz error&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to list workflows in base &#x27;gpG…</td></tr>
<tr><td class="num">9</td><td><code>attendance +get-class</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2127d89817840997588238831e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">10</td><td><code>attendance +get-global-setting</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +get-global-setting`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997604718062e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">11</td><td><code>attendance +get-group</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997620512305e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">12</td><td><code>attendance +get-group-filtered</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997638062468e08c7</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">13</td><td><code>attendance +get-leave-balance</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `attendance +get-leave-balance` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840997652901307e081a</code></td><td class="evidence">[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">14</td><td><code>attendance +list-report-columns</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +list-report-columns`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840997666188472e0756</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">15</td><td><code>attendance +query-report-leave</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +query-report-leave`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997679973065e085f</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">16</td><td><code>calendar +find-room</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997694975303e0758</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">17</td><td><code>calendar +room-find</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997709913005e0819</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">18</td><td><code>chat +category-list-conversations</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +category-list-conversations` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997724375834e0758</code></td><td class="evidence">[UNCLASSIFIED] listConversationsByCategoryV2 error hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">19</td><td><code>chat +chat-get-by-id</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-get-by-id`。</td><td><code>tools/call</code></td><td><code>2127d89817840997739165535e07bd</code></td><td class="evidence">[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">20</td><td><code>chat +chat-members-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997754345760e07bd</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">21</td><td><code>chat +chat-messages</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840997767792656e085e</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">22</td><td><code>chat +messages-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997797873841e0757</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">23</td><td><code>chat +messages-resource-url</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-resource-url`。</td><td><code>tools/call</code></td><td><code>2127d89817840997855423145e07dd</code></td><td class="evidence">[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">24</td><td><code>chat +search-msg</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997866824643e0853</code></td><td class="evidence">[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">25</td><td><code>chat +thread-replies</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +thread-replies`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997883504915e0853</code></td><td class="evidence">[UNCLASSIFIED] failed to decrypt openConvThreadId hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">26</td><td><code>contact +get-roster</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +get-roster`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2106d98117840997920166915e087b</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">27</td><td><code>contact +list-roster-fields</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +list-roster-fields`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>213ee25c17840997934295673e08e2</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">28</td><td><code>devapp +credentials-get</code></td><td><span class="risk">read</span></td><td><span class="cat">敏感/高风险暂缓</span></td><td><span class="owner">人工安全确认</span></td><td>该项涉及敏感读取或无安全负向目标,不适合自动用真实资源跑;需要在安全环境逐项人工确认。</td><td>人工确认后单独重跑 `devapp +credentials-get`,并避免在报告中泄露密钥/凭证。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。</td></tr>
<tr><td class="num">29</td><td><code>drive +download</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +download` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bab027317840997956923965e08c9</code></td><td class="evidence">[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">30</td><td><code>drive +list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +list` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2106d98117840997968627612e087b</code></td><td class="evidence">[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">31</td><td><code>minutes +action-items</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">32</td><td><code>minutes +latest-minutes</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">33</td><td><code>minutes +minutes-search</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没搜到妙记</td></tr>
<tr><td class="num">34</td><td><code>minutes +transcript</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">35</td><td><code>oa +done-approvals</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有已处理的审批记录</td></tr>
<tr><td class="num">36</td><td><code>oa +pending</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">当前没有待我审批的任务</td></tr>
<tr><td class="num">37</td><td><code>report +report-latest</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无日志</td></tr>
<tr><td class="num">38</td><td><code>todo +due-today</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">今天没有到期的待办</td></tr>
<tr><td class="num">39</td><td><code>todo +related-tasks</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有与你相关的待办(creator/executor/participant 三种角色下均为空)</td></tr>
<tr><td class="num">40</td><td><code>wiki +node-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2132f5ca17840998189126304e08d9</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">41</td><td><code>wiki +resolve-space</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有找到名称包含 DWS shortcut 真实测试 的知识空间</td></tr>
<tr><td class="num">42</td><td><code>wiki +space-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `wiki +space-list` 并比较 stdout/stderr。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">参数 --type 取值 &quot;ALL&quot; 不合法,允许值:orgWikiSpace, myWikiSpace</td></tr>
</tbody>
</table>
<h2>Write 失败逐项 <span class="count">· 114 条</span></h2>
<table class="review">
<thead><tr>
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
</tr></thead>
<tbody>
<tr><td class="num">1</td><td><code>aitable +advperm-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-disable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set adv…</td></tr>
<tr><td class="num">2</td><td><code>aitable +advperm-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-enable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set adv…</td></tr>
<tr><td class="num">3</td><td><code>aitable +attachment-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">4</td><td><code>aitable +base-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:null,&quot;message&quot;:&quot;Invalid source baseId: DWSREALTESTNOSUCHID0000000000000&quot;,&quot;retryable&quot;:null,&quot;type&quot;:&quot;USER_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Invalid sou…</td></tr>
<tr><td class="num">5</td><td><code>aitable +base-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +base-delete`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;52600003&quot;,&quot;message&quot;:&quot;Data not found&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete base DWSREALTESTNOSUCHID000…</td></tr>
<tr><td class="num">6</td><td><code>aitable +base-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">7</td><td><code>aitable +chart-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">8</td><td><code>aitable +chart-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">9</td><td><code>aitable +chart-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;config is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update chart because con…</td></tr>
<tr><td class="num">10</td><td><code>aitable +dashboard-arrange</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to align d…</td></tr>
<tr><td class="num">11</td><td><code>aitable +dashboard-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">12</td><td><code>aitable +dashboard-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">13</td><td><code>aitable +dashboard-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">14</td><td><code>aitable +field-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get current field info befor…</td></tr>
<tr><td class="num">15</td><td><code>aitable +field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get current field info for u…</td></tr>
<tr><td class="num">16</td><td><code>aitable +form-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">17</td><td><code>aitable +form-field-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">18</td><td><code>aitable +form-field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">19</td><td><code>aitable +form-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">20</td><td><code>aitable +form-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">21</td><td><code>aitable +import-data</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +import-data`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_IMPORT_ID&quot;,&quot;message&quot;:&quot;importId not found: either invalid or expired&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;impo…</td></tr>
<tr><td class="num">22</td><td><code>aitable +import-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;无法解析 baseId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;无效的 baseId&quot;,&quot;trace_id&quot;:&quot;0bab027317840998…</td></tr>
<tr><td class="num">23</td><td><code>aitable +record-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete records&quot;,&quot;trace_id&quot;:&quot;…</td></tr>
<tr><td class="num">24</td><td><code>aitable +record-primary-doc-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;RESOLVE_DOC_ID_ERROR&quot;,&quot;message&quot;:&quot;Failed to resolve docId from baseId&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to c…</td></tr>
<tr><td class="num">25</td><td><code>aitable +record-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_RECORDS&quot;,&quot;message&quot;:&quot;records must contain at least one writable record&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Fa…</td></tr>
<tr><td class="num">26</td><td><code>aitable +record-upsert</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMETER&quot;,&quot;message&quot;:&quot;records is required and must not be empty&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;records …</td></tr>
<tr><td class="num">27</td><td><code>aitable +role-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to create …</td></tr>
<tr><td class="num">28</td><td><code>aitable +role-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete role because role…</td></tr>
<tr><td class="num">29</td><td><code>aitable +role-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to patch role because roleI…</td></tr>
<tr><td class="num">30</td><td><code>aitable +section-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to create …</td></tr>
<tr><td class="num">31</td><td><code>aitable +section-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">32</td><td><code>aitable +section-move-node</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to move no…</td></tr>
<tr><td class="num">33</td><td><code>aitable +section-rename</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to rename …</td></tr>
<tr><td class="num">34</td><td><code>aitable +section-reorder</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to reorder…</td></tr>
<tr><td class="num">35</td><td><code>aitable +table-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">36</td><td><code>aitable +table-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update table DWSREALTESTNOSU…</td></tr>
<tr><td class="num">37</td><td><code>aitable +view-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">38</td><td><code>aitable +view-duplicate</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to duplica…</td></tr>
<tr><td class="num">39</td><td><code>aitable +view-lock</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to lock_or…</td></tr>
<tr><td class="num">40</td><td><code>aitable +view-set-fill-color-rule</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;conditionalFormats is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set fill col…</td></tr>
<tr><td class="num">41</td><td><code>aitable +view-set-frozen-cols</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set fro…</td></tr>
<tr><td class="num">42</td><td><code>aitable +view-set-row-height</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set cel…</td></tr>
<tr><td class="num">43</td><td><code>aitable +view-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">44</td><td><code>aitable +workflow-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Cannot resolve base &#x27;DWSREALTESTNOSUCHID0000000000000&#x27;, please check if the baseId is valid&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;sta…</td></tr>
<tr><td class="num">45</td><td><code>aitable +workflow-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Cannot resolve base &#x27;DWSREALTESTNOSUCHID0000000000000&#x27;, please check if the baseId is valid&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;sta…</td></tr>
<tr><td class="num">46</td><td><code>attendance +boss-check</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>213ee25c17840998998942184e087d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">47</td><td><code>attendance +create-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0bab027317840999009926838e090b</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">48</td><td><code>attendance +create-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2106d98117840999021121258e08b8</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">49</td><td><code>attendance +import-schedule</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999034845189e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">50</td><td><code>attendance +save-leave-balance</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +save-leave-balance`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999045751134e079c</code></td><td class="evidence">[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">51</td><td><code>attendance +update-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999058236471e08b5</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">52</td><td><code>attendance +update-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999068826691e087a</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">53</td><td><code>attendance +update-group-members</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999081094644e07dd</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">54</td><td><code>attendance +update-leave-type</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +update-leave-type`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999093924897e07fe</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">55</td><td><code>calendar +respond-event</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `calendar +respond-event`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999105062121e07db</code></td><td class="evidence">[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">56</td><td><code>chat +category-add-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-add-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999117776203e08f9</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">57</td><td><code>chat +category-remove-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-remove-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999130466520e085e</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">58</td><td><code>chat +chat-add-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +chat-add-bot`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999144193460e08c7</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">59</td><td><code>chat +chat-audit-join</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999154832733e0758</code></td><td class="evidence">[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">60</td><td><code>chat +chat-mute-member</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999166036583e08a3</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">61</td><td><code>chat +chat-quit</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-quit` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999176728426e0779</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">62</td><td><code>chat +chat-remove-bot</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-remove-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999189888305e08b5</code></td><td class="evidence">[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">63</td><td><code>chat +chat-role-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999200483204e079c</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">64</td><td><code>chat +chat-role-remove-user</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove-user` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999211317952e0757</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">65</td><td><code>chat +chat-transfer-owner</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999222318863e087a</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">66</td><td><code>chat +chat-update-icon</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-update-icon` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840999232478654e0819</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">67</td><td><code>chat +chat-update-settings</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/shortcut 枚举</span></td><td>把测试输入的 setting-key 从 x 改为后端支持的 key;同时可在 shortcut flag 上补 enum,避免用户传非法 key。</td><td>改 runner 后重跑;如果补 enum,跑 shortcut 单测确认校验文案。</td><td><code>tools/call</code></td><td><code>2127d89817840999244326971e07dd</code></td><td class="evidence">[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">68</td><td><code>chat +conversation-clear-messages</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999254816721e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">69</td><td><code>chat +conversation-clear-red-point</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999265511295e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">70</td><td><code>chat +conversation-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999276117140e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">71</td><td><code>chat +conversation-mark-read</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2127d89817840999287654280e07bd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">72</td><td><code>chat +conversation-mark-unread</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999298744910e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">73</td><td><code>chat +conversation-mute</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999309241865e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">74</td><td><code>chat +conversation-mute-at-all</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999320028068e07dd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">75</td><td><code>chat +conversation-mute-red-envelope</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999330228283e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">76</td><td><code>chat +conversation-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999341302961e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">77</td><td><code>chat +messages-add-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999352941910e0756</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">78</td><td><code>chat +messages-add-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999362862711e08b5</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">79</td><td><code>chat +messages-batch-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-recall-by-bot`。</td><td><code>tools/call</code></td><td><code>2104a64c17840999373456305e0817</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">80</td><td><code>chat +messages-batch-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999385748776e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">81</td><td><code>chat +messages-combine-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999396596163e08ee</code></td><td class="evidence">[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">82</td><td><code>chat +messages-create-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务规则</span></td><td>换成后端支持的文字表情组合,或把该命令保留为业务负向;CLI 不应绕过后端限制。</td><td>用一个真实可保存的表情模板重跑。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999407422891e08cd</code></td><td class="evidence">[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">83</td><td><code>chat +messages-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999417966407e08ee</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">84</td><td><code>chat +messages-forward-topic</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999428541474e07dd</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">85</td><td><code>chat +messages-recall</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999441138103e08c7</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">86</td><td><code>chat +messages-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999454382709e08a3</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">87</td><td><code>chat +messages-remove-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999467733059e08f9</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">88</td><td><code>chat +messages-remove-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999478923406e087f</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">89</td><td><code>chat +messages-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999494005921e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">90</td><td><code>chat +messages-send-card</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999509255753e081a</code></td><td class="evidence">[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">91</td><td><code>chat +messages-set-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999521117991e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">92</td><td><code>chat +messages-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999532438476e0817</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">93</td><td><code>chat +messages-unset-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999544428103e08ee</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">94</td><td><code>chat +messages-unset-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999554154221e08f9</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">95</td><td><code>devapp +event-subscribe</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-subscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999564022020e08c7</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">96</td><td><code>devapp +event-unsubscribe</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-unsubscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999575698500e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">97</td><td><code>devapp +permission-add</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-add`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999587758457e079c</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">98</td><td><code>devapp +permission-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-remove`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999598445247e085e</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">99</td><td><code>devapp +robot-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999609828961e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">100</td><td><code>devapp +robot-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-disable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999620461113e08ee</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">101</td><td><code>devapp +robot-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-enable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999632641525e0758</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">102</td><td><code>devapp +security-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +security-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999645522046e0817</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">103</td><td><code>devapp +version-create</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999656705920e0853</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">104</td><td><code>devapp +version-publish</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999667906017e075d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">105</td><td><code>ding +send-by-message</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `ding +send-by-message` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999678466213e0853</code></td><td class="evidence">[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">106</td><td><code>doc +comment-create-inline</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>2127d89817840999689931893e079c</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">107</td><td><code>doc +template-apply</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `doc +template-apply`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999701186954e0757</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">108</td><td><code>minutes +record-pause</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-pause` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999713124987e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">109</td><td><code>minutes +record-resume</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-resume` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999724452848e0758</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">110</td><td><code>minutes +record-stop</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-stop` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999735397508e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">111</td><td><code>oa +approve-by</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `oa +approve-by`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到待审批单据:待我处理的审批里没有标题/单号包含 &quot;__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__&quot; 的单据。</td></tr>
<tr><td class="num">112</td><td><code>wiki +node-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999769818495e0779</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">113</td><td><code>wiki +node-move</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999780286509e07fe</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">114</td><td><code>wiki +wiki-new-doc</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `wiki +wiki-new-doc`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到名为 &quot;__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__&quot; 的知识库;换个更完整/精确的空间名再试。</td></tr>
</tbody>
</table>
</main>
</body>
</html>
-223
View File
@@ -1,223 +0,0 @@
# DWS Shortcut — 方法论与进展交接文档(换会话续跑用)
> 目的:换新会话直接照此续跑。记录**方法论、已完成进展、如何继续、关键坑位、验证命令**。
> 分支:`feature/shortcut`(**改动全部未提交**,commit 由用户主动决定)。
---
## 0. 一句话现状
> 2026-07-09 **去冗余(重大修订)**:复盘发现 `internal/helpers/` 早有 ~697 个 `dws <svc> <verb>` 产品命令封装了 281 个 tool;1:1 shortcut 层有 235 个 tool 与之重复。按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 **213 条纯重复 shortcut**,1:1 层 511→298、总数 579→366、服务 19→16(aisearch/live/devdoc 整包移除,其 dws 命令仍由 helper 提供)。全绿+真机复验保留命令可用。**教训:建封装层前先审已有封装。**
在 `internal/shortcut/` 下建成一套声明式 shortcut 体系:**366 条命令 = 298 条 1:1 封装 + 68 条真·智能编排**(1:1 层原 511,已删 213 条纯重复——helper 层早已封装同一批 tool),另有 **~60 条封装升级到 lark 输出投影保真度**、P2 高频自动沉淀闭环、深度对齐 lark 矩阵。**全绿**(build/gofmt/vet/shortcut 全量测试 `shortcuts=366 assembled=322 validated=44 failed=0`/app 全量回归 72s/真机抽验)。
> 2026-07-09 批33(净新增 1 条·+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段,list_calendar_events + 合并忙碌区间 + 工作窗口求补集,默认今天 09:00-18:00/--from/--to/--in-days)。真机验证:今日 4 段空档(09:00-09:15/15min、12:00-13:30/90min、14:00-14:30/30min、16:00-17:15/75min),正是忙碌事件的精确补集。conflicts+free-slots 构成真实排期智能。总数 578→579、smart 67→68。全绿。
> 2026-07-09 批32b(净新增 1 条·dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两 [start,end) 重叠检测,默认今天/--in-days)。真机验证:抓到今日 9 个日程里 2 处真实冲突(技术标评审 10:00-11:00 × AIX 共创 10:30-12:00;尖角班 14:30-15:30 × 中控项目 15:00-16:00)。证明复杂写死胡同之外,纯 MCP-tool 的本地编排仍有净新增价值。总数 577→578、smart 66→67。全绿。
> 2026-07-09 批32(review lark 复杂写类 + 架构边界结论,用户指定方向):fresh review lark 复杂写 shortcut(mail +send/+reply/+forward、drive +import、doc +media-insert/download、base +record-upload-attachment),交叉 dws helpers。**关键结论——架构性死胡同,非没使劲**:① `mail +send` dws 已有 1:1 `+send`(send_email)+`+draft-*`,且 **contact 无 email 字段**→无法按名解析收件人(矩阵早 skip),也无 signature/template/lint 工具;② `drive +import`/`doc +media-*`/`base +record-upload-attachment` 核心是**本地文件字节 PUT/下载落盘**——dws 里由 helper 内部 `httpPutFile`/`http PUT/GET`(drive.go/doc.go)实现、已在 1:1 层覆盖(如 `drive upload --convert`),但 **shortcut 框架 `rt.CallMCP/CallMCPData` 只编排 MCP tool、结构上做不了原始文件 I/O**,故无法在 smart 层组合。**建议**:剩余复杂写要么卡此边界、要么已被 1:1 覆盖;真要补文件类能力应在 helper/1:1 层加命令,而非 shortcut 层。本批 review、无代码改动,总数仍 577。注:本轮触及 session 限额(8:20pm 重置)+ 分类器一度不可用,Bash 受限。
> 2026-07-09 批31(净新增 1 条):`calendar +my-free`(我自己的忙闲,自动解析当前 userId、默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名)。真机正向验证:返回今日/明日真实忙碌时段 {busy:[{start,end}],userId,free}。总数 576→577、smart 65→66。文档全量同步。全绿。
> 2026-07-09 批30(净新增 1 条):`contact +me`(当前用户 `get_current_user_profile` + 投影 `{name,userId,mobile,dept,org,email}`,agent 的「我是谁」;区别于 1:1 `+get-self` 吐冗长 `result[].orgEmployeeModel` raw)。真机正向验证:董鑫阳/202397/模型算法/钉钉。总数 575→576、smart 64→65。文档全量同步。全绿。
> 2026-07-09 批29(净新增便利读 3 条 + 真机抓修 1 bug):`oa +done-approvals`(审批历史 get_done_tasks)、`mail +recent-mail`(近期收件 list_mailbox_threads + 解析绑定邮箱/收件箱 folder)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance)。**真机抓到并修复 bug**:`+done-approvals` 原来 --limit 不传时 pageSize=0 → 后端 business error(1:1 list-executed 有默认所以正常);改为默认 pageSize=20,真机复验走空路径「没有已处理的审批记录」。+this-month 真机有效空、+recent-mail 正确报未绑定邮箱。总数 572→575、smart 61→64。文档全量同步。全绿。
> 2026-07-09 批28(净新增便利读 smart,多 agent 并行 + 手工):再建 3 条只读 smart——`oa +pending`(`list_pending_approvals` 只读列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(`get_user_todos_in_current_org` + `planFinishDateStart/End` 服务端过滤今天到期,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。真机:+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper。3 条为 dws 原生便利读、不对应 lark gap,矩阵 42/48 不变,总数 569→572、smart 58→61。文档全量同步。全绿。
> 2026-07-09 批27(写类输出扫荡收尾,确认无更多 bug):扫 smart 里丢弃 CallMCPData 结果的 3 处——`broadcast`(per-recipient 循环、最终结构化输出,OK)、`book`(弃 add-participant 结果但最终 `get_calendar_detail` 确认 + 失败回滚,OK)、`reschedule`(弃存在性 check detail 是有意的,随后打 update 结果,OK)。**无更多 silent-success bug**。结论:**输出质量扫荡完成**,只读投影 clean、写类确认结果、honor --format。剩余仅复杂 net-new gap-buildable(mail +send/drive +import 等多步写、难安全真机验)或 commit。真机+一致性改进累计 13 条,总数 569,全绿。
> 2026-07-09 批26(写类 smart 输出一致性批量修):扫 smart 里用 `fmt.Print*` / 无标准输出的。修 2 条:`chat +broadcast`(`fmt.Printf` 群发摘要忽略 --format → `rt.Output({sentCount,failedCount,sent,failed})`);`wiki +wiki-new-doc`(**原创建文档后丢弃 create_file 结果、静默 return nil**,真 UX bug 拿不到新文档 id/url → 捕获并 `rt.Output({created,space,title,result})`)。写类无法真机验(会真建/发),assemble 测试确认组装正确、低风险。总数仍 569。
> 2026-07-09 批25(+next-event 输出一致性修复 + sweep 确认多数已 clean):sweep 探 chat +conversation-list/+category-list、aitable +base-list 等——**多数 1:1 只读命令输出已 clean**(属先前 ~60 升级覆盖),保真度工作基本到位。**修复 1 条一致性**:`calendar +next-event` 原用 `fmt.Println` 打固定文本行、**忽略 --format/--jq/--fields**,改为 `rt.Output(map{event:项目投影})`(复用 +today/+week 同款投影),真机复验 `--format json` 出结构化 `{event:{title,start,end,location,eventId}}`、`--jq '.event.title'` 可用。这是 Agent 友好性修复。同时删除死代码 `shortcutNextEventSummary` + 无用 fmt import。总数仍 569。
> 2026-07-09 批24(1:1 层保真度升级续):`contact +list-followings` 原 `rt.CallMCP` 吐 `{arguments,result:{models:[…]}}` 信封噪音,改为 `CallMCPData`+`listFollowingsProject`,真机复验干净 `{count:13, followings:[{openDingTalkId}]}`。总数仍 569。**判断**:真机验证 + 保真度升级已到深度边际收益区(本批仅拍平 ID 列表);1:1 层多数只读命令要么需特定参数、要么后端权限受限、要么输出已可接受。**建议优先 commit 留存 24 批成果**(10 个真机改进 + 58 smart + 保真度升级 + P2 + 全套文档),再按需推进剩余 1:1 微升级。
> 2026-07-09 批23(验证驱动的 1:1 层保真度升级起步):真机探 1:1 只读命令,`drive +recent` 原 `rt.CallMCP` 吐冗长 raw(logId/nextCursor 噪音 + 每项巨型 docUrl + hasMore),改为 `CallMCPData`+`recentListProject`:投影 `{count, hasMore, items:[{name,nodeType,contentType,accessTime,docUrl,nodeId}], nextCursor}`,去 logId 噪音、保留分页与链接,真机复验干净。`drive +list-spaces` 真机空(有 errorCode 包裹噪音但 result.items 为空,暂不动)。总数仍 569。**注**:1:1 层仍有数十个 list 命令可类似升级,但属边际收益、量大,建议按需/被动推进,优先 commit 留存已有成果。
> 2026-07-09 批22(报告综合更新,反映真机验证战役):给 `shortcut-report.md` 新增 §2.4「真机验证战役」:记录 9 批真机验证(正向验证 20+ 条、抓修 8 个真实 bug 的表格、后端受限项、resolveUser 非 bug 澄清);`shortcut-report.html` §③ 测试表补 2 行 + 一段说明。诚实反映「assemble 合成测试盲区 → 真机验证补齐」的价值。纯文档,无代码改动,总数仍 569。
> 2026-07-09 批21(find-record 验证 + +suggest-time 保真度升级):`aitable +find-record` 真机正常(返回真实记录;cells 按字段 ID 键值、内含附件对象,天然复杂,clean 投影需 field-id→name 解析属更大改造,暂留)。**升级 1 条**:`calendar +suggest-time` 原 `rt.CallMCP` 吐 `result.recommendEventTimes[]` 且 `timeConflictAttendees:[null]` 噪音,改为 `CallMCPData`+`suggestTimeSlots`+`Output`:拍平 result、丢弃 null 冲突项,真机复验干净 `{suggestions:[{start,end}]}`(有真实冲突时才带 conflicts)。总数仍 569。**说明**:真机验证扫荡已进入边际收益递减区(明显 raw-verbose 的 wart 基本清完),剩余多为 minutes org-gated、写类、或输出已可接受。列出 12 条只读仍用 raw `rt.CallMCP` 的 smart(多为 minutes org-gated 或已验证 clean)。**升级 1 条**:`calendar +today` 原直吐 17 字段冗长事件(含完整 attendees 数组),改为 `CallMCPData`+复用 `+week` 的 `shortcutNextEventList/Start` 投影,真机复验干净输出 `{events:[{title,start,end,location,eventId}]}`(与 +week 一致 + location)。总数仍 569。剩余 raw-CallMCP 只读 smart:action-items/latest-minutes/transcript(minutes org-gated 无法真机验)、org/report-latest(已验 clean)、find-record/suggest-time/by-mobile/lookup/team(待验或权限受限)。
> 2026-07-09 批19(日历只读 smart 验证 + +free 保真度升级):`calendar +next-event` 真机正常(可读摘要「下一个日程:致拓 AI FDE 经验分享…」,但**忽略 --format json 只吐文本**,已知小瑕疵未改)。**升级 1 条**:`calendar +free` 终结步原 `rt.CallMCP` 直吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套,改为 `CallMCPData`+`freebusySlots`+`Output`,真机复验干净输出 `{who,userId,free,busy:[{start,end}]}`(董鑫阳 2026-07-10 忙 4 段)。总数仍 569。
> 2026-07-09 批18(真机验证续 + +group-members 保真度升级):**验证正常**:`contact +org`(董鑫阳→模型算法/17人,3步链)、`drive +find-file`(干净投影 {dentryId,fileSize,name,type})。**后端受限(非 bug)**:`contact +team`(列部门成员 `PAT_MEDIUM_RISK_NO_PERMISSION`)、`chat +search-msg`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。**升级 1 条**:`chat +group-members` 终结步原用 `rt.CallMCP`(直吐原始冗长 `result.list[]` + memberAvatarMediaId + arguments/errorCode 噪音),改为 `CallMCPData`+`groupMemberProject`+`Output`,真机复验干净输出 `{count, members:[{name,nick,role,openDingtalkId}]}`(刘力/怒龙/群主…)。总数仍 569。
> 2026-07-09 批17(修复批16 发现的 +at-me 投影):`chat +at-me` 原来因 `atMeMessageItems` 不认识真实两层嵌套 `result.conversationMessagesList[].messages[]` → 命中 fallback、直接吐原始结构。真机 dump 出真实结构(group 有 title/openConversationId/messages;message 有 sender/content/createTime/openConversationId),新增 `atMeFlattenGroups` 把各会话组拍平成单一消息列表、并把组的会话 title 下沉到每条消息。真机复验:43 条消息干净投影为 `{conversation,sender,text,time}`(如 conversation:"AI全栈"、sender:"龙衔")。总数仍 569。
> 2026-07-09 批16(只读 smart 真机验证扫荡 + 质量修复):真机跑一批时间/自身类只读 smart。**验证正常**:`calendar +today`(真实日程+参会人)、`calendar +week`(干净投影)、`todo +overdue`(空)、`attendance +my-attendance`(空)、`report +report-latest`("暂无日志"空路径)、`oa +my-initiated`(真实审批数据)。**修复 1 个输出 wart**:`chat +unread-chats` 每行都吐 `unread: null`——因 `unread_message_conversation_list` 根本不返回每会话未读数(在列表里即代表未读),改为「仅当 gateway 真返回未读数时才带 unread 字段」,真机复验输出已干净 `{conversationId,name}`。**已知待优化(未改)**:`chat +at-me` 返回 `result.conversationMessagesList[].messages[]` 冗长嵌套原始结构、未拍平成干净消息列表(功能正常,投影可再优化)。总数仍 569。
> 2026-07-09 批15(质量修复 + resolveUser 排查,真机):**修复** `contact +dept-members` 消歧消息 `<red>` 标记泄漏——复用 `stripHighlightTags`(resolve_dept.go)在 name 提取处剥离,真机复验消息已干净("开放平台(666202009)、技术平台-开放平台研发(1085781688)…")。注:`dept_members.go` 本身容器解析(含 deptList)+数值 deptId 早已健壮,仅 name markup 未剥。**排查澄清(非 bug)**:`resolveUser` 对 `董鑫阳` 真机端到端正常(userId 202397、部门 模型算法);但对 `秋画` 这类联系人 `search_contact_by_key_word` 返回 name/userId 全 null(仅 openDingTalkId),resolveUser 正确报「没找到」而非瞎猜——这是钉钉数据模型现实(外部/受限联系人无 userId),非代码 bug。**已知限制**:按名解析仅对「搜索能返回 userId 的组织内成员」有效。总数仍 569。
> 2026-07-09 批14(真机验证续,需具体 ID 的只读 shortcut):**正向验证过**:`chat +my-groups`(98 真实群+投影)、`aitable +base-list`/`+list-tables`(真实 base/table)、`aitable +resolve-table`(单命中 通用→99dV75A、多候选消歧,容器 key `tables` 正确,无 deptList-class bug)。**后端权限受限、无法正向验证(非代码 bug)**:`chat +chat-messages`(`PAT_MEDIUM_RISK_NO_PERMISSION`,读会话消息需更高权限)、`minutes +*`(该 org 未开启 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。结论:可验证的 read/resolve shortcut 全部投影正确,仅批13 的 resolve-dept 有真 bug 已修。
> 2026-07-09 批13(真机验证 + bug 修复,登录态 corp「钉钉」):用登录态把批9-12 只读 shortcut 打真实后端。**正向验证过**:`doc +find-doc`(10 真实文档、投影干净)、`aitable +resolve-base`(多候选真实 baseId)、`mail +find-mail-user`(命中真实用户+邮箱)、`contact +resolve-dept`(修复后返回真实候选)。**真机抓到并修复 1 个真 bug**:`contact +resolve-dept` 原来对任何真实部门名都返回「未找到」——真实 `search_dept_by_keyword` 响应容器 key 是 **`deptList`**(agent 的探测清单漏了),且 `deptName` 带 `<red>…</red>` 高亮标记、`deptId` 是数值。已修:容器加 `deptList`、`stripHighlightTags` 去标记、deptId 数值 coerce 成串(`resolve_dept.go`),真机复验通过(开放平台→666202009、财务→846624121,名称干净)。**已知遗留(未改)**:`contact +dept-members` 的消歧提示消息里 `<red>` 标记未剥离(仅 cosmetic,功能正常)。总数仍 569,本批未加新命令。
> 2026-07-09 批12(多 agent 并行,dws 原生 resolver 层):再建 3 条「按名解析 ID」智能 shortcut——`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内本地名→tableId)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,**已修数值 ID 兼容**:deptId 为 JSON number 时 coerce 成串,非 string-only)。均 0/1/多候选消歧,对标 resolveUser 各资源版。这 3 条不对应具体 lark gap(是 dws 原生便利层),故 gap-buildable/covered-smart 矩阵计数不变(42/48),仅总数 566→569、smart 55→58。文档全量同步。全绿。
> 2026-07-09 批11(多 agent 并行):再建 3 条智能 shortcut——`aitable +resolve-base`(search_bases 按名解析 baseId + 0/1/多候选消歧)、`chat +chat-messages`(群/单聊会话消息 list_conversation_message_v2 / list_individual_chat_message,ExactlyOne 互斥 + 投影)、`mail +find-mail-user`(search_mail_users 按名搜企业邮箱联系人 + 投影)。文档全量同步 566/505/55(gap-buildable 49→42、covered-smart→48)。全绿。注:本批 app 回归首跑因并发负载 flaky FAIL 一次(80s),连跑 2 次稳定 PASS(71s)——非本次改动导致。
> 2026-07-09 批10(多 agent 并行):再建 3 条智能 shortcut——`chat +thread-replies`(list_topic_replies 拉话题回复 + sender/text/time 投影)、`todo +related-tasks`(get_user_todos_in_current_org 三角色 creator+executor+participant 并集 + taskId 去重 + 投影)、`doc +find-doc`(search_documents 关键词搜文档 + title/url/type/token 投影)。均以 helper 为 ground truth、0 编造。文档全量同步到 563/503/52(report.md/html、lark-alignment.md gap-buildable 49→44、covered-smart→46、comparison.html 重生成)。全量测试 + app 回归全绿。
> 2026-07-09 续跑增量(批9·手工):新建 3 条智能 shortcut——`minutes +detail`(单命令聚合一条听记 basic/summary/keywords/transcript/todos、partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换、去重校验+逐组聚合)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片≤20/批+跨 `aitable-helper` server fanout+合并)。均以 helper 为 ground truth。**同步刷新全部文档到 560/501/49**:`shortcut-report.md`、`shortcut-report.html`、`shortcut-lark-alignment.md`(gap-buildable 49→46、covered-smart 41→44)、重生成 `shortcut-comparison.html`。全量测试 + app 回归全绿。
---
## 1. 背景与目标
- 对齐基准:`/Users/dennis/Projects/larksuite/cli`(lark-cli 的 `shortcuts/` 框架,飞书 REST API)。
- dws 执行底座:**钉钉 MCP**(粗粒度:一个 tool = 一个完整操作)。
- 目标:把 lark 的 shortcut 能力**深度对齐每一个**到 dws,并补钉钉侧系统性能力。
- 关键认知:lark 的"组合性"多源于飞书 API 细粒度(先查 token→id→再操作);钉钉 MCP 粗粒度,**lark 的多步在钉钉大量塌缩成 1:1(已被封装层覆盖)**。真正需要"编排"的是「按名解析 ID + 多工具串联 + 跨服务」——这些做成了 smart 层。
---
## 2. 架构与关键文件
```
internal/shortcut/
types.go # Shortcut / Flag / Risk 声明结构
runner.go # RuntimeContext + mount(编译成cobra) + CallMCP/CallMCPData/Output + 校验/dry-run/风险确认
validate.go # 跨字段校验 helper:MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll
register.go # Register() / Commands() / All()
shortcut_test.go# 框架单测
builtin/
builtin.go # blank-import 所有服务包 + smart 包;Commands() 汇总
coverage_test.go # ★全量测试:TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestAllHaveIntent / TestNoDuplicateCommands
<service>/ # 19 个服务包:contact/chat/calendar/todo/doc/drive/mail/wiki/minutes/oa/report/attendance/aitable/sheet/devapp/ding/aisearch/live/devdoc
<service>.go # 该服务的 1:1 封装 shortcut(var + init(){shortcut.Register(...)})
smart/ # ★真·智能层(多步/编排/按名解析/跨服务)
resolve.go # resolveUser(rt,name) 名→userId+消歧;contactUser{userID,name};extractUsers/userLabels
dm.go lookup.go assign.go book.go free.go ... # 每条一个文件
usage/ # P2 埋点:recorder.go(记形状不记值) stats.go command.go(dws shortcut list/stats/suggest/add)
userdef/ # P2 自定义 shortcut YAML 运行时加载 loader.go
internal/app/legacy.go # 接线点:newLegacyPublicCommands 里 append builtin.Commands() + userdef.Load()
internal/app/root.go # 装配 recordingToolCaller(埋点) + dws shortcut 命令
internal/helpers/*.go # ★Ground truth:钉钉真实 MCP tool 名 + 参数(callMCPTool("tool",{...}))
docs/
shortcut-plan.md # 总规划
shortcut-p2-design.md # P2 自动沉淀设计
shortcut-report.md / .html # 综合报告 + GSB
shortcut-comparison.html # 逐条三方对照(dws vs lark vs 原生MCP)
shortcut-lark-alignment.md # ★深度对齐矩阵(lark 361条逐条分析, 49 gap-buildable)
shortcut-handoff.md # 本文件
scripts/gen_shortcut_comparison.py # 生成三方对照 HTML
```
---
## 3. 框架契约(写新 shortcut 必读)
一个 shortcut = 包级 `var X = shortcut.Shortcut{...}` + `func init(){ shortcut.Register(X) }`。
```go
var SearchUser = shortcut.Shortcut{
Service: "contact", // 顶层命令
Command: "+search-user", // + 前缀,kebab-case
Product: "contact", // MCP server id(默认=Service;注意跨 server,见坑位)
Description: "...", // 一行
Intent: "自然语言:做什么/何时用/副作用", // 每条必填(TestAllHaveIntent 强制)
Risk: shortcut.RiskRead, // Read / Write / HighWrite(删除等,框架二次确认)
Flags: []shortcut.Flag{{Name:"query", Type:shortcut.FlagString, Required:true, Desc:"...", Enum:[]string{...}}},
Validate: func(rt *shortcut.RuntimeContext) error { return rt.RequireAll("query") }, // 可选
Execute: func(rt *shortcut.RuntimeContext) error { ... },
}
```
RuntimeContext 方法(`internal/shortcut/runner.go`/`validate.go`):
- 读参数:`rt.Str/Bool/Int/StrSlice(name)`、`rt.Changed(name)`
- **调 MCP 并打印**(终结步,1:1 封装用):`rt.CallMCP(tool, params) error`(用自身 Product)
- **调 MCP 拿数据**(多步/投影用,不打印,可跨 server):`rt.CallMCPData(product, tool, params) (map[string]any, error)`
- **投影输出**:`rt.Output(payload) error`(吃 --format/--jq/--fields)
- 校验:`rt.MutuallyExclusive/AtLeastOne/ExactlyOne(flags...)`、`rt.RangeInt(flag,min,max)`、`rt.RequireAll(flags...)`
- smart 复用:`resolveUser(rt, name) (contactUser, error)`(名→userId+消歧,在 smart/resolve.go)
对标 lark:`CallMCPData`≈`CallAPITyped`;`resolveUser`≈`ResolveOpenIDsTyped`;`rt.Output`≈`OutFormat`;`Validate helper`≈lark 的 MutuallyExclusive/AtLeastOne。
---
## 4. 方法论(怎么高效批量建,屡试不爽)
**核心:多 agent workflow 并行 + helper 为 ground truth + 严格 skip + build/test 门禁。**
1. **每个 shortcut/服务一个 agent**,并行(`parallel(...)`)。
2. **Ground truth 铁律**:tool 名和参数 key **只能逐字取自 `internal/helpers/<svc>.go` 的真实 `callMCPTool("tool",{params})` 调用点**,严禁编造。agent 必须先 Read+grep helper。
3. **宁缺勿错**:拿不准的 tool/参数/结构 → **skip 并说明**,不瞎写(已多次证明 agent 会正确 skip,如 mail 无 email 字段)。
4. **响应字段防御式解析**:返回结构无契约保证 → 多候选 key 探测(result/data/list/items + 字段别名),不硬编码。
5. **不同 agent 写不同文件**(服务包 vs smart 包,或不同 service 文件)→ 无写冲突;**禁止 agent 改 builtin.go**(我事后统一维护 blank import)。
6. **落地后统一**:`gofmt -w` → `go build ./...` → shortcut 全量测试 → 命名冲突用 rename 修(如 smart 的 `+approve` 撞 1:1 层 → 改 `+approve-by`)。
7. **周期性 app 全量回归**(改多个服务文件后):`go test ./internal/app/...`(~73s)验证接线。
workflow 脚本模板见任意 `~/.claude/.../workflows/scripts/build-smart-*.js` 或 `upgrade-fidelity-*.js`(每次 Workflow 调用都存了盘,可 `{scriptPath}` 复用/改)。
---
## 5. 已完成进展
### 5.1 覆盖层(511 条 1:1 封装 / 19 服务)
chat89 aitable86 mail43 attendance36 doc33 minutes31 devapp30 sheet29 calendar24 drive24 oa20 todo18 wiki15 contact14 report7 ding7 aisearch3 live1 devdoc1。每条带自然语言 Intent。
### 5.2 智能层(~46 条 `internal/shortcut/smart/`)
按名操作人/群、多步编排+失败回滚、时间/自身智能、跨服务、钉钉原生编排。已建(举例):
`chat +dm/+send-to-group/+broadcast/+group-members/+at-me/+search-msg/+unread-chats`、`contact +lookup/+org/+team/+by-mobile/+dept-members`、`calendar +book(回滚)/+free/+today/+week/+next-event/+invite/+suggest-time/+reschedule/+cancel-event/+respond-event/+find-room`、`todo +assign/+assign-multi/+overdue/+todo-done/+remind/+created-todos`、`minutes +latest-minutes/+action-items/+transcript/+minutes-search/+detail/+replace-batch`、`oa +approve-by/+my-initiated`、`attendance +my-attendance`、`report +report-latest`、`aitable +find-record/+list-tables`、`doc +share-doc/+doc-append`、`wiki +wiki-new-doc`、`drive +find-file`、`mail +search-mail/+unread-mail`。
### 5.3 框架系统性能力(对齐 lark)
`resolveUser`、`CallMCPData`、`rt.Output`、`Validate×5`。
### 5.4 保真度升级(~64 条封装:CallMCP→CallMCPData+投影+Output,对齐 lark 96% 输出投影)
覆盖 contact/chat/calendar/todo/doc/drive/mail/wiki/aitable/oa/devapp/attendance/minutes/report/sheet 等服务的列表类命令。
### 5.5 P2 高频自动沉淀(差异化,lark 无)
埋点(记形状不记值,默认关/opt-in DWS_USAGE_TRACKING=1) → `dws shortcut stats/suggest` → `dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 运行时 `userdef.Load()` 编译注册。**闭环端到端跑通。**
### 5.6 深度对齐矩阵
`docs/shortcut-lark-alignment.md`:逐条分析 lark 361 条 → covered-1to1 144 / no-dingtalk-tool 127 / **gap-buildable 49** / covered-smart 41。
---
## 6. 如何继续(下一步 backlog)
1. **保真度升级剩余列表命令**(还有部分服务的 list 命令仍是裸 CallMCP):起 `upgrade-fidelity-N` workflow,每服务 agent 挑 1-2 个未升级(`grep 'rt.CallMCP('`)的列表读命令,改成 CallMCPData+投影+Output。范式见 `contact.go` 的 `searchUserProject`/`listRolesProject`。
2. **补剩余 gap-buildable smart shortcut**(矩阵里 49 个,已建 ~20+):起 `build-smart-N` workflow(smart 包,不碰服务包)。剩余偏复杂(sheets/base 操作、消息富化、分片下载),谨慎、允许 skip。
3. **每批**:gofmt→build→shortcut 测试→(改多文件后)app 回归→更新 `docs/shortcut-report.md`。
4. **收尾**:把 `docs/shortcut-report.md/html` 的计数刷新到最终(部分 §2 测试数字可能还停在旧值 511/456,实际 ~557/~500),重跑 `python3 scripts/gen_shortcut_comparison.py`。
---
## 7. 关键坑位(务必注意)
- **跨 server 路由**:有些 tool 不在本服务 server。已知:contact 花名册 tool 走 `hrmregister`;chat 部分 tool 走 `im`/`bot`;`query_check_record` 走 `attendance-wukong`(不是 attendance);wiki `create_file` 走 `doc` server。→ 这类必须用 `rt.CallMCPData("<真实server>", ...)`,不能用 `rt.CallMCP`(它按 shortcut.Product 路由会打错 server)。判断依据:helper 里是 `callMCPToolOnServer("<server>", ...)`。
- **命名冲突**:smart 命令别撞 1:1 层(如 `+approve`→用 `+approve-by`,`+freebusy`→用 `+free`)。`TestNoDuplicateCommands` 会抓。
- **参数别名**:aitable 查询关键词是 `keyword`(不是 query);todo 建待办用嵌套 `PersonalTodoCreateVO`;日程 create/update 时间是 ISO 字符串,而 list/busy 是毫秒——一切以 helper 调用点为准。
- **中文 const tool 名**:minutes 录音 tool 是中文 `"执行听记指令-发起AI听记录音"`(const `listeningNoteCmdTool`)——真实,别当编造。
- **测试真机验证**:token 有时效(`dws auth status` 看 expires),过期会报错非代码问题。真机跑命令时第一次有 catalog 发现 banner(stderr),结果 JSON 在后面,别用 `head` 截断。
- **工具标签格式**(给 AI:本会话我多次误用错标签导致工具调用失败——务必用正确的 function-call 格式)。
---
## 8. 验证命令速查
```bash
cd /Users/dennis/Projects/dingtalk-workspace/dingtalk-workspace-cli
go build ./... # 编译
gofmt -l internal/shortcut/ # 格式(应空)
go test ./internal/shortcut/... # shortcut 全量(含 assemble/tool-real/intent/no-dup)
go test ./internal/app/... # app 回归(~73s,改服务文件后必跑)
go test ./internal/shortcut/builtin/ -run TestAllShortcutsAssemble -v 2>&1 | grep shortcuts= # 看总数
# 真机(需登录 dws auth login)
DWS_USAGE_TRACKING=0 dws contact +lookup --name <真实姓名> # 智能多步示范
DWS_USAGE_TRACKING=0 dws calendar +today # 时间智能
DWS_USAGE_TRACKING=0 dws contact +search-user --query <名> # 投影输出示范
```
测试口径:`TestAllShortcutsAssemble` = 假 Caller 拦截,给每条命令(含写/删)喂合成参数、走完解析→校验→组装 MCP 调用、断言 tool 真实无 panic(零副作用全量验证)。`TestAllToolLiteralsAreReal` = 所有 CallMCP tool 名比对 helper ground truth 防编造。
---
## 9. 未提交提醒
所有工作在 `feature/shortcut`,**未 commit**。建议尽快分语义化 commit 留存(框架 / 511封装 / smart层 / 保真度升级 / P2 / 文档)。
-204
View File
@@ -1,204 +0,0 @@
# DWS Shortcut P2 详细设计 — 高频场景自动沉淀为自定义 Shortcut
> 前置:P1 已交付静态声明式 shortcut 框架(`internal/shortcut/`),见 `docs/shortcut-plan.md`。
> P2 目标:**观察用户高频使用 → 主动建议 → 一键沉淀为可复用的自定义 shortcut**,
> 让 CLI 越用越顺手。这是 dws 相对 larksuite/cli 的差异化能力。
## 0. 体验闭环(一句话)
```
用户反复敲 dws chat send_message --json '{"open_conversation_id":"cid_x","text":"..."}'
│ (每次执行被静默记录到 ~/.dws/usage.jsonl)
▼
第 N 次后,dws 主动提示:
💡 你已 12 次向「项目群」发消息,是否沉淀为 `dws chat +notify-team`?[y/N]
│ y
▼
写入 ~/.dws/shortcuts/chat.notify-team.yaml
▼
之后:dws chat +notify-team --text "发布完成" ← 参数从一堆 JSON 收敛成一个 flag
```
## 1. 总体架构
复用 P1 的 `Shortcut` 模型作为「编译目标」,新增四个部件:
| 部件 | 位置(建议) | 职责 |
|------|-------------|------|
| Usage 埋点 | `internal/shortcut/usage/recorder.go` | 每次 MCP 调用后追加一条 usage 记录 |
| 模式挖掘 | `internal/shortcut/usage/miner.go` | 聚合 usage → 高频候选 + 打分 |
| 主动提示 | `internal/shortcut/usage/nudge.go` | 命中候选时在命令收尾处提示 |
| YAML 加载 | `internal/shortcut/userdef/loader.go` | 扫描 `~/.dws/shortcuts/*.yaml` → 编译成 `Shortcut` → 注册 |
| 管理命令 | `internal/shortcut/usage`(cobra) | `dws shortcut list/suggest/add/rm/stats` |
数据流:
```
CallMCP ──► recorder.Append(usage) [写侧,热路径,必须极轻]
┌─► miner.TopCandidates() [读侧,suggest 时才算]
~/.dws/usage.jsonl ─────────────────┤
└─► nudge (命令收尾抽样触发)
~/.dws/shortcuts/*.yaml ──► loader.Compile() ──► shortcut.Register() [启动时]
```
## 2. Usage 埋点
### 2.1 采集点(choke point)
**首选**:装饰 `executor.Runner` / `edition.ToolCaller`。`internal/app/tool_caller_adapter.go`
的 `CallTool(ctx, productID, toolName, args)` 是**所有 MCP 调用的唯一必经点**,天然拿到
`(product, tool, args)` 三元组。用装饰器包一层即可,零侵入命令层:
```go
type recordingCaller struct{ inner edition.ToolCaller }
func (r recordingCaller) CallTool(ctx, product, tool string, args map[string]any) (*edition.ToolResult, error) {
res, err := r.inner.CallTool(ctx, product, tool, args)
usage.Append(product, tool, args, err == nil) // 异步/带 recover,绝不影响主流程
return res, err
}
```
> 注意:P1 的 shortcut 也走这条 `CallMCP → helpers → deps.Caller`,所以内建 shortcut 的
> 使用同样会被记录,可用于「哪些内建 shortcut 最受欢迎」的洞察。
### 2.2 记录内容(**隐私优先:记形状不记值**)
`~/.dws/usage.jsonl`,每行一条:
```json
{
"ts": "2026-07-08T10:12:33+08:00",
"product": "chat",
"tool": "send_message",
"arg_keys": ["open_conversation_id", "text"],
"const_args": {"open_conversation_id": "cid_x"},
"ok": true
}
```
- `arg_keys`:参数键集合(排序),用于识别「同一种调用形状」。
- `const_args`:**仅收敛出的「疑似固定值」**(见 §3 挖掘时判定),写入时不保证脱敏,
因此需要一层白名单/黑名单:`text/content/body/message` 等自由文本字段**永不入库**,
只保留看起来像 ID/枚举的短值(长度阈值 + 无空格 + 非多行)。
- 绝不记录:token、手机号、邮箱、文件内容、消息正文。用 `internal/logging/redact.go`
已有的脱敏能力复核。
### 2.3 热路径约束
- 追加写用 `O_APPEND`,单行 < 1KB;失败静默(`recover` + debug 日志),**绝不阻断命令**。
- 文件滚动:超过 N 行(如 5000)或 M 天,截断/归档,避免无限增长。
- 开关:默认关闭(opt-in),环境变量 `DWS_USAGE_TRACKING=1` 开启(本地遥测即便只记形状也不应未经用户同意默认开启)。
首次启用时在 `dws` 首跑给一次性告知(尊重知情)。
## 3. 模式挖掘
`dws shortcut suggest` 触发(也被 nudge 复用)。算法:
1. 读 usage.jsonl,按 `(product, tool, arg_keys)` 分桶。
2. 对每桶:
- `count` = 出现次数;低于阈值(默认 5)直接丢弃。
- 对每个 arg_key,统计其值的分布:某值占比 ≥ 80% → 判定为**固定值**(进 `const_args`);
否则判定为**可变参数**(沉淀后成为 flag)。
- `recency` = 最近一次使用距今;越近权重越高。
3. 打分 `score = count * log(distinct_days+1) * recencyDecay`,取 TopN。
4. 生成候选 `Candidate{product, tool, fixed{...}, varFlags[...], score, samples}`。
输出示例(`dws shortcut suggest --format table`):
```
候选 | 命令建议 | 依据 | 固定参数 | 可变flag
#1 | chat +notify-team | 12 次 / 近 3 天 | open_conv=cid_x | text
#2 | doc +new-agenda | 7 次 / 近 5 天 | template=agenda | title
```
## 4. 主动提示(nudge)
- **时机**:命令成功收尾时(root `PersistentPostRunE`),**抽样**触发(如每 N 次调用或每次
命中新达标候选时),避免打扰。仅在 TTY 交互态提示;非交互(Agent/管道/`--yes`)**不提示**。
- **频控**:同一候选提示过一次被拒后,冷却期内不再提示(记 `~/.dws/shortcuts/.declined`)。
- **交互**:
```
💡 检测到高频操作:你已 12 次向同一会话发消息。
沉淀为快捷指令 dws chat +notify-team --text "..." ?
[y] 沉淀 [n] 以后再说 [d] 不再提示此项
```
- y → 走 §5 生成 YAML;命名默认 `+<tool 去下划线的动宾>`,允许用户改名。
## 5. 自定义 Shortcut:YAML 格式与运行时加载
### 5.1 YAML schema(与 P1 `Shortcut` 一一对应)
`~/.dws/shortcuts/chat.notify-team.yaml`:
```yaml
version: 1
service: chat
command: "+notify-team"
product: chat
description: "发消息到 项目群(自动沉淀于 2026-07-08)"
risk: write # 默认 read;send 类判定为 write
source: auto # auto=沉淀 / manual=手写
flags:
- name: text
type: string
required: true
desc: 消息内容
execute:
tool: send_message
bind: # 参数绑定:常量 + ${flag} 模板
open_conversation_id: "cid_x"
text: "${text}"
```
### 5.2 编译与注册
`userdef.Compile(yaml)` → `shortcut.Shortcut`,其 `Execute` 由 `bind` 生成:
遍历 `bind`,`${flag}` 用 `rt.Str(flag)` 填充,常量原样,组装 params 后 `rt.CallMCP(tool, params)`。
完全复用 P1 的 runner,不新增执行路径。
加载时机:`legacy.go` 装配点,在 `builtin.Commands()` 之后追加 `userdef.Commands()`,
一起 merge。复用 `internal/plugin/loader.go` 已验证的「扫 `~/.dws/` 目录 + 挂 cobra」模式。
### 5.3 冲突与优先级
- 自定义 shortcut 命令名若与内建 shortcut / helper 冲突:**内建优先**,自定义重命名或跳过并告警。
- `+` 前缀天然与 helper leaf 区分,冲突面小。
## 6. 管理命令面
```
dws shortcut list # 列出内建 + 自定义 shortcut
dws shortcut suggest [--min N] # 展示高频候选(不写入)
dws shortcut add <candidate|--from-last> # 交互式/从最近一次调用沉淀
dws shortcut rm <service> <+cmd> # 删除自定义 shortcut
dws shortcut stats # usage 统计概览
```
`dws shortcut` 本身作为一个新的顶层 utility 命令注册(对齐 `dws plugin`)。
## 7. 安全与隐私边界(红线)
1. **值不入库**:自由文本/正文/凭证一律不记;`const_args` 仅短 ID/枚举,且过 redact 复核。
2. **可关可清**:`DWS_USAGE_TRACKING=0` 关闭;`dws shortcut stats --purge` 清空 usage。
3. **执行白名单**:自定义 shortcut 的 `execute.tool` 必须解析到合法 MCP server(过
`internal/security` endpoint 白名单),禁止指向任意 endpoint。
4. **不自动执行**:沉淀只生成命令定义,**绝不**自动发起写操作;写类 shortcut 仍受 P1 的
risk 确认约束。
5. **知情**:首次开启埋点一次性告知;提示可永久关闭。
## 8. 实现顺序(P2 分步,便于 loop 推进)
- P2-1 usage 埋点:`recordingCaller` 装饰器 + `usage.Append` + jsonl 写 + 开关 + 脱敏白名单。
- P2-2 `dws shortcut stats` / `list`:先让数据可见,验证埋点质量。
- P2-3 miner + `dws shortcut suggest`:离线挖掘与打分。
- P2-4 userdef YAML 加载 + `Compile` + 注册 + 冲突处理(打通「手写 YAML 也能用」)。
- P2-5 `dws shortcut add`(从候选/最近调用沉淀)。
- P2-6 nudge 主动提示(最后做,最谨慎,默认保守频控)。
## 9. 待决策点(需产品确认)
1. 埋点默认开还是默认关?→ **修订后:默认关(opt-in)+ 开启后首跑一次性告知**(原设计默认开,反思后改为 opt-in:自主 agent 不应单方面默认开本地遥测)
(`DWS_USAGE_TRACKING=0` / 配置项关闭;`dws shortcut stats --purge` 清空)。实现时以此为准。
2. `const_args` 允许记录的字段白名单粒度?(保守起步:只记形如 `*_id/*Id/type/status` 的短值)
3. nudge 触发频率与渠道?(建议:仅 TTY、命中新候选时、每候选一生仅一次)
4. 自定义 shortcut 是否需要跨设备同步?(v1 先本地 `~/.dws/`,同步留待后续)
-127
View File
@@ -1,127 +0,0 @@
# DWS Shortcut 能力 — 总体规划
> 目标:为 dws 引入一套 **声明式高保真命令(Shortcut)** 能力,对齐 larksuite/cli 的 `+command`
> 体验(如 `lark-cli contact +search-user`),并在此之上做 dws 差异化:**基于用户高频使用场景,
> 主动把常用操作沉淀为自定义 shortcut**。
## 1. 背景与动机
dws 当前的命令有三类来源:
1. **MCP 运行时动态发现** —— `dws mcp <service> <tool> --json '{...}'`,通用但裸、参数需手拼 JSON。
2. **`internal/helpers/` 产品命令** —— 手写 cobra 命令,体验好但每个都从零写、缺统一框架。
3. **`internal/registry/recipes.yaml`** —— 多步工作流的静态描述。
痛点:想新增一个「精选、参数友好、带 dry-run/format/身份」的单命令,只能手写 helper,
没有统一的声明式框架,重复劳动多、一致性差。larksuite 的 shortcut 框架正好解决这一层。
## 2. 与 larksuite/cli 的架构差异(关键)
| 维度 | larksuite/cli | dws-cli |
|------|---------------|---------|
| 命令来源 | 静态硬编码 Go shortcut | MCP 运行时动态发现 + helpers |
| 调用底座 | Lark SDK 直连 API | MCP JSON-RPC(`executor.Runner`) |
| 精选命令层 | `shortcuts/`(200+ 声明式) | `internal/helpers/`(手写 cobra) |
| 全局 flag | 框架注入 | root 已内建 `--format/--dry-run/--jq/--yes/--fields/--profile` |
**结论**:不能直接搬代码。移植的是 shortcut 的**声明式设计**,执行底座换成 dws 的
`executor.Runner`,全局能力复用 dws 已有的 output/safety/auth。
## 3. 分期目标
### P1 — 静态声明式框架(本期,正在做)
- 新建独立模块 `internal/shortcut/`(零侵入现有 helpers)。
- `types.go`:`Shortcut` / `Flag` / `RuntimeContext` 声明层。
- `runner.go`:把 `Shortcut` 编译成 `*cobra.Command`,串起 flag 注册 → 校验 → dry-run →
`executor.Runner.Run` → `output.WriteCommandPayload`。
- `register.go`:按 service 分组产出命令,在 `internal/app/legacy.go` 装配点 merge 进命令树。
- 样板命令 `contact +search-user`:打通 MCP 执行 / format / dry-run / 身份,作为后续命令模板。
- 交付判据:`dws contact +search-user --help`、`--dry-run` 正常;`go build` / `go test` 通过。
### P2 — 高频场景自动沉淀(后续,先设计再实现)
- **使用埋点**:命令执行入口记录 `~/.dws/usage.jsonl`(只记参数形状,不记敏感值)。
- **模式挖掘**:`dws shortcut suggest` 聚合高频 `(service, tool, 固定参数组合)`。
- **主动沉淀**:命中候选时提示用户,一键写入 `~/.dws/shortcuts/*.yaml`(声明式,与 P1 结构对应)。
- **运行时加载**:`register.go` 额外扫描 `~/.dws/shortcuts/*.yaml` 动态注册,复用
`internal/plugin/loader.go` 已验证的「从 `~/.dws/` 加载并挂 cobra 命令」模式。
## 4. 落地方式(P1)
采用**独立模块 + 装配点 merge**,不改 helpers 内部:
```
internal/shortcut/
types.go # Shortcut / Flag / RuntimeContext
runner.go # 声明式→cobra 编译 + 执行管道
register.go # Commands(runner) []*cobra.Command,按 service 分组
contact/
search_user.go # 样板:var SearchUser = shortcut.Shortcut{...}
shortcuts.go # Shortcuts() []shortcut.Shortcut
```
接线:`internal/app/legacy.go: newLegacyPublicCommands` 里,
`helpers.NewPublicCommands(runner)` 之后追加 `shortcut.Commands(runner)`,
一起走 `mergeTopLevelCommands`(同名 service 命令自动合并,`+xxx` 作为其子命令)。
复用点:
- 执行:`executor.NewHelperInvocation` + `runner.Run`(与 helper 完全一致的调用路径)。
- 输出:`output.WriteCommandPayload(cmd, resp, output.FormatJSON)`(自动吃 root 的 `--format/--jq/--fields`)。
- dry-run:读 root `--dry-run`,置 `Invocation.DryRun`,由 runner 返回请求预览。
- 身份/安全:复用 `--profile`、`internal/safety`(高风险 `--yes` 确认)。
## 5. Shortcut 声明模型(草案)
```go
type Shortcut struct {
Service string // "contact" → 顶层命令
Command string // "+search-user" → 子命令(保留 + 前缀,对齐 larksuite)
Description string
Risk string // read | write | high-risk-write
Flags []Flag
Validate func(*RuntimeContext) error
Execute func(*RuntimeContext) error // 必填;内部调 rt.CallMCP(...)
}
type Flag struct {
Name, Type, Default, Desc string
Required bool
Enum []string
}
```
`RuntimeContext` 给 Execute 提供:flag 读取(`Str/Bool/Int/StrSlice/Changed`)、
`CallMCP(product, tool, params)`(内部 `runner.Run`)、`Output(payload)`、`DryRun()`。
## 6. 风险与边界
- **与 `dws mcp` 通道的边界**:shortcut 是「人工精选的薄封装」,不替代通用 MCP 通道;
一个 tool 可以既能 `dws mcp` 直调,也能有 shortcut。
- **自定义 shortcut 安全(P2)**:YAML 的 `execute` 若允许任意 MCP 调用,需过
`internal/security` 的 endpoint 白名单,且沉淀的参数值要脱敏。
- **命名冲突**:`+` 前缀天然与现有 leaf 命令区分,降低与 helper 命令的冲突面。
- **edition 差异**:oss / enterprise 的可用 service 不同,注册时按 edition 过滤(后续接入)。
## 7. 进度看板
- [x] P1-1 types.go — `Shortcut` / `Flag` / `Risk` 声明层
- [x] P1-2 runner.go — `RuntimeContext` + `mount` 编译 + 校验/确认/dry-run;`CallMCP` 委托 `helpers.CallMCPToolOnServer`(复用错误分类/输出/dry-run)
- [x] P1-3 register.go + `internal/shortcut/contact/search_user.go` + `builtin` 聚合包
- [x] P1-4 接线 `legacy.go`(append 到 `mergeTopLevelCommands`)+ build/test 全绿
- [ ] P2 设计文档(进行中)
### P1 落地实证(已验证)
- `dws contact +search-user --help`:命令挂载,继承全局 `--format/--dry-run/--jq/...`。
- 必填校验:不传 `--query` → 结构化 validation 错误。
- `--dry-run`:走 helpers 路径输出 `[DRY-RUN]` 预览(tool + 参数)。
- 命令树 merge:`+search-user` 与现有 `user/dept/label/relation` 共存,`contact user search` 未受影响。
- 测试:`internal/shortcut` 单测通过;`internal/app` 全量回归通过。
### P1 关键决策记录
- **执行底座复用 helpers 而非裸 `runner.Run`**:`CallMCP` 委托 `helpers.CallMCPToolOnServer(product, tool, params)`,
一步获得错误分类(auth/PAT/业务)+ 格式化输出 + dry-run,避免重造劣质输出层。代价是
`internal/shortcut → internal/helpers` 的单向依赖(无环)。后续若要 shortcut 做多调用编排/输出重塑,
再补一个返回原始 payload 的 `CallMCPRaw`。
- **避免 import 环**:service 包(contact)import 核心 `shortcut` 包并在 `init()` 注册;
`builtin` 聚合包 blank-import 各 service 包;`app` 只依赖 `builtin`。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-168
View File
@@ -1,168 +0,0 @@
# Shortcut 真实测试跟进清单
生成时间:`2026-07-15T16:58:39`
来源:`docs/shortcut-real-read-results.json` 与 `docs/shortcut-real-write-results.json`。
口径:记录真实后端测试中需要继续定位的 case,用于 CR 和问题分派;Agent 使用入口以公开 shortcut catalog 和产品 skill 为准。
总计:156 条。
| # | suite | shortcut | risk | status | category | fixability | 处理依据 |
|---:|---|---|---|---|---|---|---|
| 1 | read | `aitable +base-get-primary-doc-id` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 2 | read | `aitable +chart-share-get` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 3 | read | `aitable +dashboard-share-get` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 4 | read | `aitable +export-data` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 5 | read | `aitable +record-primary-doc-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 6 | read | `aitable +role-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 7 | read | `aitable +workflow-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 8 | read | `aitable +workflow-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 9 | read | `attendance +get-class` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 10 | read | `attendance +get-global-setting` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 11 | read | `attendance +get-group` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 12 | read | `attendance +get-group-filtered` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 13 | read | `attendance +get-leave-balance` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 14 | read | `attendance +list-report-columns` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 15 | read | `attendance +query-report-leave` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 16 | read | `calendar +find-room` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 17 | read | `calendar +room-find` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 18 | read | `chat +category-list-conversations` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 19 | read | `chat +chat-get-by-id` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 20 | read | `chat +chat-members-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 21 | read | `chat +chat-messages` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 22 | read | `chat +messages-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 23 | read | `chat +messages-resource-url` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 24 | read | `chat +search-msg` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 25 | read | `chat +thread-replies` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 26 | read | `contact +get-roster` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 27 | read | `contact +list-roster-fields` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 28 | read | `devapp +credentials-get` | read | held | held | manual-approval | 高风险或无安全目标,需人工逐项授权后执行。 |
| 29 | read | `drive +download` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 30 | read | `drive +list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 31 | read | `minutes +action-items` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 32 | read | `minutes +latest-minutes` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 33 | read | `minutes +minutes-search` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 34 | read | `minutes +transcript` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 35 | read | `oa +done-approvals` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 36 | read | `oa +pending` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 37 | read | `report +report-latest` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 38 | read | `todo +due-today` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 39 | read | `todo +related-tasks` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 40 | read | `wiki +node-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 41 | read | `wiki +resolve-space` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 42 | read | `wiki +space-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 43 | write | `aitable +advperm-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 44 | write | `aitable +advperm-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 45 | write | `aitable +attachment-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 46 | write | `aitable +base-copy` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 47 | write | `aitable +base-delete` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 48 | write | `aitable +base-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 49 | write | `aitable +chart-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 50 | write | `aitable +chart-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 51 | write | `aitable +chart-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 52 | write | `aitable +dashboard-arrange` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 53 | write | `aitable +dashboard-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 54 | write | `aitable +dashboard-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 55 | write | `aitable +dashboard-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 56 | write | `aitable +field-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 57 | write | `aitable +field-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 58 | write | `aitable +form-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 59 | write | `aitable +form-field-hide` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 60 | write | `aitable +form-field-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 61 | write | `aitable +form-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 62 | write | `aitable +form-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 63 | write | `aitable +import-data` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 64 | write | `aitable +import-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 65 | write | `aitable +record-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 66 | write | `aitable +record-primary-doc-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 67 | write | `aitable +record-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 68 | write | `aitable +record-upsert` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 69 | write | `aitable +role-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 70 | write | `aitable +role-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 71 | write | `aitable +role-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 72 | write | `aitable +section-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 73 | write | `aitable +section-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 74 | write | `aitable +section-move-node` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 75 | write | `aitable +section-rename` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 76 | write | `aitable +section-reorder` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 77 | write | `aitable +table-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 78 | write | `aitable +table-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 79 | write | `aitable +view-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 80 | write | `aitable +view-duplicate` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 81 | write | `aitable +view-lock` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 82 | write | `aitable +view-set-fill-color-rule` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 83 | write | `aitable +view-set-frozen-cols` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 84 | write | `aitable +view-set-row-height` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 85 | write | `aitable +view-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 86 | write | `aitable +workflow-disable` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 87 | write | `aitable +workflow-enable` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 88 | write | `attendance +boss-check` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 89 | write | `attendance +create-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 90 | write | `attendance +create-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 91 | write | `attendance +import-schedule` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 92 | write | `attendance +save-leave-balance` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 93 | write | `attendance +update-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 94 | write | `attendance +update-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 95 | write | `attendance +update-group-members` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 96 | write | `attendance +update-leave-type` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 97 | write | `calendar +respond-event` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 98 | write | `chat +category-add-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 99 | write | `chat +category-remove-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 100 | write | `chat +chat-add-bot` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 101 | write | `chat +chat-audit-join` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 102 | write | `chat +chat-mute-member` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 103 | write | `chat +chat-quit` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 104 | write | `chat +chat-remove-bot` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 105 | write | `chat +chat-role-remove` | high-risk-write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 106 | write | `chat +chat-role-remove-user` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 107 | write | `chat +chat-transfer-owner` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 108 | write | `chat +chat-update-icon` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 109 | write | `chat +chat-update-settings` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 110 | write | `chat +conversation-clear-messages` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 111 | write | `chat +conversation-clear-red-point` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 112 | write | `chat +conversation-hide` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 113 | write | `chat +conversation-mark-read` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 114 | write | `chat +conversation-mark-unread` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 115 | write | `chat +conversation-mute` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 116 | write | `chat +conversation-mute-at-all` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 117 | write | `chat +conversation-mute-red-envelope` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 118 | write | `chat +conversation-set-top` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 119 | write | `chat +messages-add-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 120 | write | `chat +messages-add-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 121 | write | `chat +messages-batch-recall-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 122 | write | `chat +messages-batch-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 123 | write | `chat +messages-combine-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 124 | write | `chat +messages-create-text-emotion` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 125 | write | `chat +messages-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 126 | write | `chat +messages-forward-topic` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 127 | write | `chat +messages-recall` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 128 | write | `chat +messages-recall-by-bot` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 129 | write | `chat +messages-remove-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 130 | write | `chat +messages-remove-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 131 | write | `chat +messages-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 132 | write | `chat +messages-send-card` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。 |
| 133 | write | `chat +messages-set-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 134 | write | `chat +messages-set-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 135 | write | `chat +messages-unset-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 136 | write | `chat +messages-unset-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 137 | write | `devapp +event-subscribe` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 138 | write | `devapp +event-unsubscribe` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 139 | write | `devapp +permission-add` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 140 | write | `devapp +permission-remove` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 141 | write | `devapp +robot-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 142 | write | `devapp +robot-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 143 | write | `devapp +robot-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 144 | write | `devapp +security-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 145 | write | `devapp +version-create` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 146 | write | `devapp +version-publish` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 147 | write | `ding +send-by-message` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 148 | write | `doc +comment-create-inline` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 149 | write | `doc +template-apply` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 150 | write | `minutes +record-pause` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 151 | write | `minutes +record-resume` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 152 | write | `minutes +record-stop` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 153 | write | `oa +approve-by` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 154 | write | `wiki +node-copy` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 155 | write | `wiki +node-move` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 156 | write | `wiki +wiki-new-doc` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
-186
View File
@@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DWS Shortcut 完成情况报告</title>
<style>
:root{--bg:#0f1420;--card:#161d2c;--ink:#e6edf6;--muted:#93a1b5;--line:#26314a;
--blue:#4f9cff;--green:#3fb950;--yellow:#d5a429;--red:#f25c5c;--accent:#6ea8fe;--purple:#a371f7}
*{box-sizing:border-box}
body{margin:0;background:linear-gradient(180deg,#0d1220,#0f1420);color:var(--ink);
font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:0 0 80px}
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
header{padding:52px 22px 28px;text-align:center;border-bottom:1px solid var(--line);
background:radial-gradient(1200px 300px at 50% -60px,rgba(79,156,255,.16),transparent)}
h1{font-size:29px;margin:0 0 8px;letter-spacing:.5px}
.sub{color:var(--muted);font-size:14px}
.stats{display:flex;gap:13px;justify-content:center;flex-wrap:wrap;margin:26px 0 4px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 20px;min-width:118px}
.stat .n{font-size:27px;font-weight:700;color:var(--accent)}
.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
h2 .ico{color:var(--accent);margin-right:8px}
h3{font-size:15.5px;margin:22px 0 9px;color:var(--accent)}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px;background:var(--card);
border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
th{background:#1b2536;color:var(--muted);font-weight:600;font-size:12.5px}
tr:last-child td{border-bottom:none}
td.c,th.c{text-align:center}
.num{color:var(--accent);font-weight:700;text-align:center}
.g{color:var(--green);font-weight:700}.s{color:var(--yellow);font-weight:700}.b{color:var(--red);font-weight:700}
.ok{color:var(--green)}.star{color:var(--yellow)}
code{background:#0c1120;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#cfe0ff}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 18px;margin:13px 0}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.layer{border-radius:12px;padding:16px 18px}
.l-wrap{background:linear-gradient(180deg,rgba(79,156,255,.08),transparent);border:1px solid #234b6b}
.l-smart{background:linear-gradient(180deg,rgba(163,113,247,.10),transparent);border:1px solid #4a3a6b}
.layer h3{margin-top:0}
.pill{display:inline-block;background:#12283a;color:#7fc6ff;border:1px solid #234b6b;border-radius:6px;padding:1px 7px;font-size:12px;margin:2px 3px 2px 0}
.flow{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
.flow b{color:var(--ink)}.flow .arw{color:var(--purple)}
.concl{background:linear-gradient(90deg,rgba(63,185,80,.10),transparent);border-left:3px solid var(--green);padding:14px 18px;border-radius:8px;margin-top:16px}
.keyfind{background:linear-gradient(90deg,rgba(213,164,41,.10),transparent);border-left:3px solid var(--yellow);padding:14px 18px;border-radius:8px;margin:14px 0}
footer{color:var(--muted);text-align:center;font-size:12.5px;margin-top:40px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>DWS Shortcut 完成情况报告</h1>
<div class="sub">对齐基准 larksuite/cli · 执行底座 钉钉 MCP · 随 loop 持续更新</div>
<div class="stats">
<div class="stat"><div class="n">366</div><div class="l">shortcut 总数</div></div>
<div class="stat"><div class="n">298</div><div class="l">1:1 封装层</div></div>
<div class="stat"><div class="n">68</div><div class="l">真·智能层</div></div>
<div class="stat"><div class="n">16</div><div class="l">覆盖服务</div></div>
<div class="stat"><div class="n">0</div><div class="l">失败/panic/编造</div></div>
</div>
</header>
<div class="wrap">
<h2><span class="ico">①</span>做了什么:两个层次</h2>
<p>诚实区分——shortcut 分两层,价值定位不同,不混为一谈。</p>
<div class="two">
<div class="layer l-wrap">
<h3>🔵 1:1 封装层 · 298 条</h3>
<div style="color:var(--muted);font-size:13.5px">一个 shortcut ≡ 一个 MCP tool。把裸 <code>dws mcp &lt;svc&gt; &lt;tool&gt; --json '{…}'</code> 收敛成命名 flag,附校验/风险确认/Intent。</div>
<div style="margin:10px 0"><b>价值</b>:DX 与 AI-agent 可发现性,<b>不是新能力</b>。</div>
<div><span class="pill">命名 flag</span><span class="pill">required/enum 校验</span><span class="pill">风险确认</span><span class="pill">自然语言 Intent</span><span class="pill">dry-run/format</span></div>
</div>
<div class="layer l-smart">
<h3>🟣 真·智能层 · 68 条</h3>
<div style="color:var(--muted);font-size:13.5px">照 lark-cli 范式的多步/编排/智能,<b>不是 1:1</b>。框架新增 <code>CallMCPData</code>(多步取数,对标 lark <code>CallAPITyped</code>)+ <code>resolveUser</code>(名→ID,对标 <code>ResolveOpenIDsTyped</code>)。</div>
<div style="margin:10px 0"><b>价值</b>:<b>这才是「shortcut 作为新能力」</b>。</div>
<div><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">按名解析+消歧</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">多工具编排</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">失败回滚</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">跨服务</span></div>
</div>
</div>
<h2><span class="ico">②</span>真·智能层 68 条明细(节选)</h2>
<table>
<thead><tr><th>shortcut</th><th>多步/智能逻辑</th><th class="c">验证</th></tr></thead>
<tbody>
<tr><td><code>chat +dm --to &lt;名&gt;</code></td><td>搜人→解析 userId→发单聊;多人消歧</td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>contact +lookup --name &lt;名&gt;</code></td><td>搜人→解析→取完整资料</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>todo +assign --to &lt;名&gt;</code></td><td>解析人→建待办并设执行人</td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>contact +org --name &lt;名&gt;</code></td><td>解析人→取 deptId→查部门详情(3 步)</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>contact +team --name &lt;名&gt;</code></td><td>解析人→取部门→列部门成员</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +free --who &lt;名&gt;</code></td><td>解析人→查其时段忙闲</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>calendar +book [--with &lt;名CSV&gt;]</code></td><td>建日程→按名加参与者→<b>失败回滚删日程</b></td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>calendar +invite --event --with</code></td><td>解析多人→加入已有日程</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +suggest-time --with</code></td><td>解析多人→推荐可开会时间</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +today</code></td><td>算今天范围→列我今天日程</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>calendar +next-event</code></td><td>近 7 天→取最近一个日程</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +reschedule --event</code></td><td>查日程详情→改时间</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +send-to-group --group &lt;群名&gt;</code></td><td>按群名搜群→消歧→发消息</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +group-members --group &lt;群名&gt;</code></td><td>搜群→列群成员</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +broadcast --to &lt;名CSV&gt;</code></td><td>多名逐一解析→群发单聊,失败汇总</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>todo +todo-done --task &lt;关键词&gt;</code></td><td>列我待办→按标题匹配→标完成</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>todo +remind --task --at</code></td><td>给自己建带提醒的待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>wiki +wiki-new-doc --space &lt;名&gt;</code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +share-doc --to &lt;名&gt; --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
</tbody>
</table>
<div class="card">
<b>质量亮点(agent 严守 ground truth)</b>:<code>+send-to-group</code> 纠正了「按群名搜群」的正确工具(<code>search_groups</code> 而非按成员昵称的 <code>search_common_groups</code>);<code>+wiki-new-doc</code> 发现 <code>create_file</code> 在 doc server 并正确跨服务路由;<code>+mail-to</code> 因钉钉无 email 字段<b>主动 skip 拒绝编造</b>。
</div>
<h2><span class="ico">③</span>测试验证(零副作用全量)</h2>
<p>写/删命令不能真跑,用<b>假 Caller 拦截</b>——每条命令走完「解析→校验→确认→组装 MCP 调用」,捕获组装出的 <code>(product,tool,params)</code>,不真发网络。</p>
<table>
<thead><tr><th>验证项</th><th>范围</th><th>结果</th></tr></thead>
<tbody>
<tr><td><code>go build ./...</code> / gofmt / vet</td><td>全仓</td><td class="ok">✅ 0 告警</td></tr>
<tr><td>TestAllShortcutsAssemble</td><td>全部 366</td><td class="ok">✅ 322 组装真实MCP · 44 自校验 · 0 失败/panic</td></tr>
<tr><td>TestAllToolLiteralsAreReal</td><td>tool 字面量</td><td class="ok">✅ 0 编造(比对 helper ground truth)</td></tr>
<tr><td>TestAllHaveIntent</td><td>全部 366</td><td class="ok">✅ 每条均有自然语言描述</td></tr>
<tr><td>TestNoDuplicateCommands</td><td>全部</td><td class="ok">✅ 无重复 · 命名规范</td></tr>
<tr><td>usage / userdef 单测</td><td>埋点/沉淀</td><td class="ok">✅ 全通过</td></tr>
<tr><td>app 包全量回归</td><td>internal/app</td><td class="ok">✅ ~72s 通过(未破坏现有命令)</td></tr>
<tr><td>智能层真机验证(9 批)</td><td>只读/解析类 20+ 条</td><td class="ok">✅ 端到端返回真实数据、投影正确</td></tr>
<tr><td>真机抓修真实 bug</td><td>合成测试盖不住的投影/解析偏差</td><td class="ok">✅ 修复 8 处(resolve-dept/at-me/group-members/free/today/suggest-time/unread-chats/dept-members)</td></tr>
</tbody>
</table>
<p style="color:var(--muted);font-size:13px">真机验证补齐了 assemble 测试的盲区:合成响应验证不了「防御式投影是否匹配真实响应结构」。9 批真机验证抓到并修复 8 处解析/投影偏差(如 <code>+resolve-dept</code> 漏了真实容器 key <code>deptList</code>、<code>+at-me</code> 未拍平嵌套、<code>+today/+free</code> 直吐冗长 raw)。少数命令(chat 会话消息读、minutes)因 org/PAT 权限受限无法真机跑通,组装链路仍由 assemble 测试覆盖。</p>
<h2><span class="ico">④</span>效果评估 GSB(vs lark-cli)</h2>
<h3>4.1 能力覆盖 GSB(按 dws 实际暴露的 MCP tool 数 = helper∪shortcut,非 shortcut 数)</h3>
<table>
<thead><tr><th>lark 服务</th><th class="c">lark</th><th>dws</th><th class="c">dws</th><th class="c">GSB</th><th>说明</th></tr></thead>
<tbody>
<tr><td>im</td><td class="c">21</td><td>chat</td><td class="c">95</td><td class="c g">G</td><td>群/消息/机器人更全</td></tr>
<tr><td>mail</td><td class="c">21</td><td>mail</td><td class="c">43</td><td class="c g">G</td><td>覆盖更广</td></tr>
<tr><td>doc</td><td class="c">14</td><td>doc</td><td class="c">34</td><td class="c g">G</td><td>块级读写更细</td></tr>
<tr><td>minutes</td><td class="c">14</td><td>minutes</td><td class="c">25</td><td class="c g">G</td><td>录音/说话人更全</td></tr>
<tr><td>calendar</td><td class="c">12</td><td>calendar</td><td class="c">24</td><td class="c g">G</td><td>会议室/ACL 更全</td></tr>
<tr><td>contact</td><td class="c">2</td><td>contact</td><td class="c">15</td><td class="c g">G</td><td>部门/角色/花名册更全</td></tr>
<tr><td>wiki</td><td class="c">12</td><td>wiki</td><td class="c">16</td><td class="c g">G</td><td>略优</td></tr>
<tr><td>base</td><td class="c">87</td><td>aitable</td><td class="c">79</td><td class="c s">S</td><td>持平;helper 仅 16,<b>shortcut 补齐 +63</b>(真 gap-fill)</td></tr>
<tr><td>task</td><td class="c">18</td><td>todo</td><td class="c">20</td><td class="c s">S</td><td>持平</td></tr>
<tr><td>drive</td><td class="c">26</td><td>drive</td><td class="c">25</td><td class="c s">S</td><td>持平</td></tr>
<tr><td>apps</td><td class="c">63</td><td>devapp</td><td class="c">25</td><td class="c b">B</td><td><b>helper 无 devapp,25 全由 shortcut 补</b>,但仍少于 lark</td></tr>
<tr><td>sheets</td><td class="c">84</td><td>sheet</td><td class="c">60</td><td class="c b">B</td><td>钉钉表格 MCP 较少;helper 已覆盖</td></tr>
<tr><td>vc/okr/slides/markdown/whiteboard/note/event</td><td class="c">62</td><td>—</td><td class="c">0</td><td class="c b">B</td><td>钉钉无对应能力,<b>客观不可对齐</b></td></tr>
</tbody>
</table>
<div style="color:var(--muted);font-size:13px;margin:6px 0 2px">🟢 G 7 领域 · 🟡 S 3 领域 · 🔴 B(apps/sheets 少于 lark + 6 领域钉钉无能力)。base 的"持平"几乎全靠 shortcut gap-fill(helper 仅 16)。dws 独有:oa/attendance/report/ding/aisearch/live/devdoc。</div>
<h3>4.2 组合/智能层 GSB(关键发现)</h3>
<div class="keyfind">
<b>关键结论</b>:lark 有 ~104 个组合(≥2 次 API)shortcut,但 <b>lark 的组合性多源于飞书 REST API 太细粒度</b>(要先查 spreadsheetToken→sheetId→再操作);<b>钉钉 MCP 是粗粒度的——一个 tool = 一个完整操作</b>,所以 lark 的组合在钉钉这边<b>大量塌缩成 1:1</b>(已被封装层覆盖),或<b>根本没有对应 tool</b>。
</div>
<table>
<thead><tr><th>lark 组合来源</th><th class="c">数量</th><th class="c">GSB</th><th>钉钉现实</th></tr></thead>
<tbody>
<tr><td>sheets(先解析 sheetId)</td><td class="c">41</td><td class="c s">S</td><td>钉钉直接吃 token → 1:1 层已覆盖</td></tr>
<tr><td>apps db-env/audit/log/trace</td><td class="c">17</td><td class="c b">B</td><td>钉钉无对应工具</td></tr>
<tr><td>drive/doc/im(上传/媒体/搜索)</td><td class="c">23</td><td class="c s">S</td><td>多为钉钉 1:1 已覆盖</td></tr>
<tr><td>calendar/contact/wiki/minutes/todo 编排</td><td class="c">~10</td><td class="c g">G</td><td>✅ 已建为真·智能 shortcut(+book/+lookup/+org/+wiki-new-doc/+reschedule…)</td></tr>
<tr><td>okr/whiteboard/slides/vc</td><td class="c">11</td><td class="c b">B</td><td>钉钉无对应能力</td></tr>
</tbody>
</table>
<div style="color:var(--muted);font-size:13px">→ 钉钉真正需要「组合」的场景(按名解析+多工具编排+跨服务),dws 已覆盖并<b>额外做了 lark 没有的</b>(+today/+broadcast/+share-doc/+action-items 等)。<b>不盲目复刻 lark 的机械多步</b>(在钉钉会成冗余假组合)。</div>
<h2><span class="ico">⑤</span>dws 差异化优势</h2>
<div class="card"><b>复用生产级 MCP 通道</b>(架构性)—— <code>CallMCP</code> 统一继承错误分类(auth/PAT/业务)、dry-run、<code>--format/--jq/--fields</code>;lark 每命令各自实现。</div>
<div class="card"><b>协作能力覆盖更全</b> —— chat 95/mail 43/doc 34/minutes 25(dws tool 覆盖)是 lark 对应 2–4 倍。</div>
<div class="card"><b>钉钉原生特有能力</b>(lark 完全没有)—— 审批/日志/考勤/DING/企业智能搜索,75 条差异化封装。</div>
<div class="card"><b>真·智能编排(22 条)</b> —— 按名解析+消歧、失败回滚、跨服务;<code>resolveUser</code>/<code>CallMCPData</code> 让新智能 shortcut 越写越快。</div>
<div class="card"><b>高频自动沉淀(P2 · lark 无此设计)</b>
<div class="flow" style="margin-top:9px"><b>高频使用</b><span class="arw">→</span><b>埋点</b><span class="arw">→</span><b>suggest</b><span class="arw">→</span><b>add 写 YAML</b><span class="arw">→</span><b>运行时加载可用</b></div>
</div>
<div class="card"><b>工程质量</b> —— 假 Caller 拦截,366 条(含写/删)零副作用全量验证,可复跑回归。</div>
<div class="concl">
<b>一句话结论</b>:dws 已把钉钉侧<b>能对齐的都对齐</b>(366 条 = 298 封装 + 68 智能 / 16 服务,1:1 层已去 213 条纯重复),即时协作显著优于 lark,拥有审批/考勤/DING 等原生差异化能力与「高频自动沉淀」独有闭环。lark 的组合优势多因飞书 API 细粒度、在钉钉粗粒度 MCP 下塌缩为 1:1(已覆盖),真正需编排的钉钉侧已建齐;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量验证。
</div>
<footer>DWS Shortcut Report · 由持续精进 loop 维护 · 另见 <a href="shortcut-comparison.html">逐条三方对照 HTML</a> · <a href="shortcut-report.md">Markdown 版</a></footer>
</div>
</body>
</html>
-289
View File
@@ -1,289 +0,0 @@
# DWS Shortcut 能力整合与对齐报告
> 版本:截至本轮 loop | 对齐基准:larksuite/cli(lark-cli) | 执行底座:钉钉 MCP
> 相关文档:[总规划](shortcut-plan.md) · [P2 自动沉淀设计](shortcut-p2-design.md) · [HTML 报告](shortcut-report.html) · [**逐条三方对照 HTML**](shortcut-comparison.html)(每个 shortcut:dws +命令 vs lark-cli vs 原生 MCP 组合)
---
## 1. Shortcut 整合了哪些能力
`dws` 现内建 **298 个 1:1 封装 shortcut** + **68 条 smart 智能编排命令**(§1.3)= **合计 366 条**,覆盖 **16 个钉钉服务**,以 `dws <service> +<command>` 形式提供。
> ⚠️ **重要修订(去冗余)**:1:1 层原为 511 条,**复盘发现 `internal/helpers/` 早已把大量 MCP tool 封装成 `dws <svc> <verb>` 产品命令**——其中 **213 条 1:1 shortcut 只是把已被 helper 封装过的同一个 tool 用 `+` 前缀又封了一遍、且无输出投影增量,属纯重复**,已删除。**保留的 298 条 = 233 条填 helper 空白(helper 从没封装的 tool)+ 65 条虽 tool 重复但加了干净投影**。这是对"建 1:1 层前没先摸清 helper 已封装什么"的纠偏(详见 §5 复盘)。aisearch/live/devdoc 三个服务的 shortcut 全属纯重复、已整包移除(其 `dws <svc>` 命令仍由 helper 层提供)。
### 1.1 能力清单(按服务,prune 后)
| 服务 | 1:1 shortcut 数 | 覆盖能力(摘要) |
|------|:---:|------|
| chat(群聊/消息) | 79 | 群管理、群成员、群身份角色、消息收发/撤回/转发/表情/卡片、会话置顶/免打扰、消息分组、机器人 |
| aitable(多维表 base) | 77 | 数据表/字段/记录/视图/表单/仪表盘/图表/角色/协作全生命周期 |
| attendance(考勤)★ | 33 | 打卡记录、审批、排班、班次、考勤组、统计报表、请假 |
| devapp(开放平台应用 apps) | 30 | 应用增删改查、成员、权限、版本发布、事件订阅、扩展机器人/H5 配置 |
| doc(文档) | 16 | 文档/文件夹、正文块读写、权限、附件、节点 |
| contact(通讯录) | 9 | 用户/部门搜索与详情、角色、花名册 |
| drive(钉盘) | 8 | 文件/文件夹管理、下载、复制移动、权限、最近访问 |
| calendar(日历) | 8 | 日程、参与人、会议室、忙闲、ACL、日历本 |
| minutes(AI 听记) | 7 | 妙记详情/逐字稿、录音控制、说话人 |
| oa(审批)★ | 6 | 审批实例、单据处理、模板、流程 |
| mail(邮箱) | 6 | 邮件搜索/线程、标签、联系人、收信规则(投影类保留) |
| wiki(知识库) | 5 | 知识空间、节点、成员 |
| todo(待办 task) | 5 | 待办、子任务、执行人/参与人、附件 |
| ding(DING)★ | 5 | 机器人/个人 DING 发送、撤回、接收状态 |
| sheet(钉钉表格) | 2 | 区域读写(投影类保留) |
| report(日志)★ | 2 | 日志收件箱/发件箱 |
| **合计** | **298** | **16 个服务** |
★ = 钉钉特有服务,lark-cli 无对应(详见 §3 GSB)。**注**:多数服务的 `shortcut 数` 已远小于该服务的 MCP tool 总数——因为 tool 的基础封装由 helper 层的 `dws <svc> <verb>` 命令承担,1:1 shortcut 只保留 helper 没覆盖的、或加了投影的。
### 1.2 每个 shortcut 统一具备的能力(框架注入)
不是简单命令别名,而是叠加在裸 MCP 之上的**精选薄封装**,统一获得:
- **声明式定义**:`Shortcut{Service, Command, Product, Risk, Flags, Execute}`,一处声明、框架编译成 cobra 命令。
- **自然语言 Intent**:每条 shortcut 均带一段自然语言描述(做什么/何时用/关键输入产出,写删类点明副作用),面向用户与 AI agent 的意图匹配;`--help` 展示为长描述,`dws shortcut list` 输出 `intent` 字段。全部 366 条覆盖(`TestAllHaveIntent` 强制校验)。
- **参数收敛**:把裸 `dws mcp <svc> <tool> --json '{...}'` 的手拼 JSON,收敛成命名 flag(`--query`/`--group`…)。
- **内建校验**:required / enum 声明式校验,结构化错误提示。
- **风险确认**:read / write / high-risk-write 分级,写/删操作 `--yes` 前二次确认。
- **复用生产级 MCP 通道**:错误分类(auth/PAT/业务)、`--dry-run` 预览、`--format`/`--jq`/`--fields` 输出,全部免费继承(详见 §4)。
### 1.3 两个层次:1:1 封装层 vs 真·多步/智能层(重要澄清)
诚实区分——上面 298 条**绝大多数是 1 shortcut ≡ 1 个 MCP tool 的 1:1 封装**,本质是「给 MCP 套命名 flag + 校验 + Intent 的友好外壳」,价值在 DX 与 agent 可发现性,**不是新能力**。
真正的「shortcut 作为新能力」是 `internal/shortcut/smart/` 下的**多步/智能** shortcut——照 larksuite/cli 的实现范式(`CallAPITyped` 链式多步、按名解析 ID、Validate、DryRun 计划、失败回滚)落地。框架为此新增 `RuntimeContext.CallMCPData(product, tool, params)`(对应 lark 的 `CallAPITyped`:调用并返回 data 供下一步,跨服务)。
已落地的真·智能 shortcut(`internal/shortcut/smart/`,共 68 条,下表为代表性节选):
| shortcut | 多步/智能逻辑 | 验证 |
|----------|--------------|------|
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
| `calendar +suggest-time --with <姓名CSV>` | 解析多人→推荐可开会时间 | ✅ 编译/挂载 |
| `calendar +invite --event <id> --with <姓名CSV>` | 解析多人→加入已有日程 | ✅ 编译/挂载 |
| `doc +share-doc --to <姓名> --url` | 解析人→把文档链接私信 TA | ✅ 编译/挂载 |
| `calendar +today` | 算出今天时间范围→列我今天的日程 | ✅ **真机端到端**(返回真实日程+参会人) |
| `calendar +next-event` | 近 7 天日程→按时间取最近一个 | ✅ 编译/挂载 |
| `contact +team --name <姓名>` | 解析人→取部门→列部门直接成员 | ✅ 编译/挂载 |
| `todo +remind --task --at` | 给自己建带截止/提醒时间的待办 | ✅ 编译/挂载 |
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
| `oa +approve-by --keyword` ★ | 列待审批→匹配→取 taskId→通过(钉钉原生,lark 无) | ✅ 编译/挂载 |
| `attendance +my-attendance` ★ | 当前用户→算今天→查我打卡(路由 attendance-wukong server) | ✅ 编译/挂载 |
| `todo +overdue` | 列我待办→本地过滤过期→投影输出 | ✅ **真机端到端** |
| `report +report-latest` ★ | 列我日志→取最新→取详情 | ✅ 编译/挂载 |
| `aitable +find-record --base --table` | 表内按关键词查记录 | ✅ 编译/挂载 |
另有 gap-buildable 补齐(批6):`chat +my-groups`(列群+类型过滤+投影)、`calendar +find-room`(时段找可用会议室)、`minutes +minutes-search`(关键词搜妙记)、`mail +search-mail`(搜邮件+自动解析绑定邮箱)、`drive +find-file`(搜钉盘文件+投影)。
批7-8 续补(10 条):`chat +at-me`(近期@我)、`calendar +cancel-event`(查→删,高危二次确认)、`todo +assign-multi`(多人指派)、`contact +dept-members`(搜部门→列成员)、`minutes +transcript`(最新妙记逐字稿)、`calendar +week`(本周日程)、`contact +by-mobile`(手机号→资料)、`todo +created-todos`(我创建的)、`chat +unread-chats`(未读会话)、`mail +unread-mail`(未读邮件)。
批9 续补(3 条·手工,对齐 gap-buildable):`minutes +detail`(单命令聚合一条听记的 basic/summary/keywords/transcript/todos,partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合,补齐一次一组的 1:1 `+word-replace`)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片(≤20/批)+跨 `aitable-helper` server fanout+合并,补齐单批 20 条上限)。
批10 续补(3 条·多 agent 并行,对齐 gap-buildable):`chat +thread-replies`(拉某条话题消息的全部回复 list_topic_replies + sender/text/time 投影)、`todo +related-tasks`(creator+executor+participant 三角色并集「与我相关的待办」+ taskId 去重 + 投影)、`doc +find-doc`(按关键词搜云文档 search_documents + title/url/type/token 投影)。
批11 续补(3 条·多 agent 并行):`aitable +resolve-base`(按名搜 Base 解析 baseId,0/1/多候选消歧 search_bases)、`chat +chat-messages`(群/单聊会话消息列表,list_conversation_message_v2 / list_individual_chat_message 互斥+投影)、`mail +find-mail-user`(按名/邮箱搜企业邮箱联系人 search_mail_users + 投影)。
批12 续补(3 条·多 agent 并行,dws 原生 resolver 层,按名解析 ID):`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内名→tableId,本地匹配)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,含数值 ID 兼容)。均 0/1/多候选消歧,对标 `resolveUser` 的各资源版。
批28 续补(3 条·净新增便利读,dws 原生):`oa +pending`(**只读**列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(今天到期待办,planFinishDate 服务端过滤,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。均只读、真机验证(+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper)。
批29 续补(3 条·净新增便利读,dws 原生):`oa +done-approvals`(我已处理的审批历史 get_done_tasks;真机抓到并修复 pageSize=0 → 默认 20 的后端报错 bug)、`mail +recent-mail`(近期收件箱会话 list_mailbox_threads + 解析绑定邮箱/收件箱)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance 自身解析)。真机:+this-month 返回有效空、+done-approvals 修后走空路径、+recent-mail 正确报未绑定邮箱。
批30 续补(1 条·净新增便利读):`contact +me`(当前用户 get_current_user_profile + 投影 {name,userId,mobile,dept,org,email},agent 的「我是谁」;区别于 1:1 +get-self 吐冗长 raw,真机验证 董鑫阳/202397/模型算法)。
批31 续补(1 条·净新增便利读):`calendar +my-free`(我自己的忙闲,自动解析当前 userId,默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名,真机验证返回今日忙碌时段)。
批32 续补(1 条·净新增 dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两重叠检测,默认今天/--in-days;真机验证抓到今日 2 处真实冲突)。这类纯 MCP-tool 的本地编排是复杂写死胡同之外仍有价值的方向。
批33 续补(1 条·净新增 dws 原生编排,+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段"什么时候能安排会",list_calendar_events + 合并忙碌区间 + 工作窗口内求补集,默认今天 09:00-18:00/--from/--to/--in-days;真机验证今日 4 段空档)。
共 **68 条真·智能 shortcut**(多批多 agent 工作流并行生成 + 手工续补)。★=钉钉原生编排,lark 完全没有。
**真机验证(登录态抽样,返回真实数据)**:`calendar +today/+week`(真实日程+投影)、`contact +org`(3 步→部门详情)、`contact +lookup/+free`、`todo +overdue`、`attendance +my-attendance` 等端到端可用。
### 深度对齐矩阵(逐条分析 lark 361 条 shortcut)
见 [`shortcut-lark-alignment.md`](shortcut-lark-alignment.md)——12 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉:
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 (40%) | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 (35%) | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **42 (12%)** | 钉钉有工具、值得补成智能 shortcut(建设目标) |
| covered-smart | 48 (13%) | 已建智能 shortcut / 部分覆盖 |
**框架系统性能力已对齐 lark**:`resolveUser`(名→ID)· `CallMCPData`(多步取数)· `rt.Output`(输出投影)· `rt.MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll`(跨字段校验)。
### 保真度升级(对齐 lark 96% 的输出投影)
lark 96% 的 shortcut 都做**输出投影**(把原始 API 返回精简为干净字段列表)。已给 **~60 条列表/读类封装**升级到此保真度——从 `rt.CallMCP`(打印原始 MCP 返回)改为 `rt.CallMCPData` + 防御式投影 + `rt.Output`(自动吃 `--format/--jq/--fields`):
`contact +search-user/+search-mobile/+list-roles/+list-sub-depts` · `todo +get-my-tasks/+list-sub` · `calendar +book-list/+attendee-list` · `drive +list` · `wiki +node-list` · `chat +conversation-list/+category-list/+messages-list-unread-conversations/+messages-list-pin` · `doc +search/+list` · `mail +tag-list/+contact-list` · `aitable +base-list/+base-search` · `oa …`
示例:`contact +search-user` 由原始 MCP 返回 → 干净 `{count, users:[{name,userId,flowerName,openDingTalkId,title}]}`(真机验证)。这是把封装层往 lark 高保真水平系统性拉升的开始。另有 `mail +to`(按名发邮件)被 agent **正确 skip**——钉钉 contact 无 email 字段、mail `send_email` 需发件人邮箱 `from` 无法解析,宁缺勿错不编造。关键复用:「按名解析人」抽成共享 helper `resolveUser`(对标 lark `ResolveOpenIDsTyped`,带 0/多人消歧,不瞎猜);多步靠 `CallMCPData`(对标 lark `CallAPITyped`)。其中 5 条由多 agent 工作流并行生成——各自以 helper 为 ground truth 研究参数、`send-to-group` 的 agent 还主动纠正了「按群名搜群」的正确工具(`search_groups` 而非按成员昵称的 `search_common_groups`)。
> 定位:1:1 层是「MCP 友好外壳」,smart 层才是「真 shortcut」。二者不混淆。
---
## 2. 测试验证报告
**验证理念**:写/删命令不能真跑(会发消息、解散群、删数据),故用**假 Caller 拦截**——让每条命令(含写/删)真实走完「解析→校验→确认→组装 MCP 调用」全流程,捕获组装出的 `(product, tool, params)`,只是不真发网络。以此对**全部 366 条**做零副作用验证。
### 2.1 结果总览(全绿)
| 验证项 | 范围 | 结果 |
|------|------|------|
| `go build ./...` | 全仓 | ✅ 通过 |
| `gofmt -l` / `go vet` | shortcut 全包 | ✅ 0 未格式化 / 0 告警 |
| 框架单元测试(5) | 类型/挂载/校验/分组 | ✅ 全通过 |
| **TestAllShortcutsAssemble** | **全部 366 条** | ✅ 322 组装真实 MCP · 44 自校验拦截 · **0 失败 · 0 panic** |
| **TestAllToolLiteralsAreReal** | 全部 tool 字面量(逐条) | ✅ **0 编造**(tool 名逐一比对 helper ground truth) |
| TestNoDuplicateCommands | 全部 366 条 | ✅ 无重复、命名规范(均 `+` 前缀) |
| **TestAllHaveIntent** | 全部 366 条 | ✅ 每条均有自然语言 Intent 描述(无一遗漏) |
| usage 包单测(4) | 埋点/脱敏/聚合/开关 | ✅ 全通过 |
| app 包全量回归 | `internal/app` | ✅ 72.2s 通过(接线未破坏任何现有命令) |
| 只读命令真机验证 | ~25 条(登录态,见 §2.4) | ✅ `contact +me`/`+org`/`+lookup`、`calendar +today/+week/+free/+conflicts/+free-slots`、`doc +find-doc`、`aitable +resolve-base/+resolve-table`、`drive +find-file`、`oa +my-initiated` 等端到端返回真实数据、投影核对 |
### 2.2 关键指标解读
- **322 「组装真实 MCP」**:喂合成参数后成功组装出 MCP 调用,且 tool 名经 helper ground truth 核验真实、非编造。
- **44 「自校验拦截」**:这些命令有结构化/JSON/互斥输入(如多维表建记录需 JSON、DING 三选一接收人),dummy 值被其**自身校验正确拒绝**——证明校验链路健全。其 tool 名由静态测试 `TestAllToolLiteralsAreReal` 单独覆盖,无遗漏。
- **0 编造 / 0 panic / 0 失败**:无幻觉工具名,无运行时崩溃,无死命令。
> ⚠️ **验证强度分层(诚实口径,勿把"全绿"读成"真机全对")**:`TestAllShortcutsAssemble` 的"0 失败"只证明**能正确组装 MCP 调用、零副作用**——它用**合成响应**,**验证不了防御式投影是否匹配真实响应结构**(真机验证正是靠这个抓到过 deptList 容器、pageSize=0 等 assemble 盖不住的 bug,见 §2.4)。按真机验证强度分三层:**(A) 真机正向验证** ~25 条只读/解析类(返回真实数据、投影核对);**(B) 仅 assemble + 复用已验证 helper**(如 +due-today/+this-month 等,逻辑同构于已验证命令,但该条本身未在真机跑出正样本);**(C) 未对真实后端跑过**——17 条写类 smart(不宜真跑,会发消息/建数据)、6 条 minutes smart(该 org 未开 CLI 数据访问)、mail +recent-mail(无绑定邮箱)。(C) 类**很可能仍有 assemble 盖不住的投影/参数 bug**,不应因"全绿"就当作"真机可用"。
### 2.3 防幻觉机制(工作流生成时)
生成阶段每个服务由独立 agent 负责,硬性规则:tool 名与参数 key **只能逐字取自 dws helper 的真实调用点**,无法确定参数的 tool 主动跳过并记录原因(如嵌套对象、时间戳转换、本地文件分片上传)。测试阶段再用 ground truth 二次核验,双重保险。
### 2.4 真机验证战役(登录态打真实钉钉后端)
assemble 测试用**合成响应**,能验证「调用是否组装正确」,但验证不了「防御式投影解析是否匹配真实响应结构」。为此做了 9 批真机验证(登录态 corp「钉钉」,token 有效期内),把只读/解析类 smart shortcut 打真实后端、逐条核对投影输出。
**正向验证 20+ 条**(返回真实数据、投影正确):`doc +find-doc`、`aitable +resolve-base`/`+resolve-table`/`+list-tables`/`+base-list`/`+find-record`、`mail +find-mail-user`、`chat +my-groups`/`+group-members`/`+at-me`、`contact +org`/`+lookup`、`calendar +today`/`+week`/`+next-event`/`+free`/`+suggest-time`、`todo +overdue`/`+related-tasks`、`attendance +my-attendance`、`report +report-latest`、`oa +my-initiated`、`drive +find-file`、`wiki +resolve-space` 等。
**真机抓到并修复 8 个真实问题**(assemble 测试抓不到,只有真机能抓):
| shortcut | 真机发现的问题 | 修复 |
|---|---|---|
| `contact +resolve-dept` | 对任何真实部门名都「未找到」——真实响应容器 key 是 `deptList`(防御探测清单漏了),deptName 带 `<red>` 高亮、deptId 是数值 | 加 `deptList` 探测 + `stripHighlightTags` + 数值 coerce |
| `contact +dept-members` | 消歧消息泄漏 `<red>` 标记 | 复用 `stripHighlightTags` |
| `chat +unread-chats` | 每行吐 `unread: null`(底层不返回每会话未读数) | 仅当有值才带该字段 |
| `chat +at-me` | 直吐原始两层嵌套、未拍平 | 新增 `atMeFlattenGroups`,拍平 43 条为 `{conversation,sender,text,time}` |
| `chat +group-members` | 终结步 raw `CallMCP` 吐冗长 raw(含 avatar 媒体 ID + errorCode 噪音) | 升级 `CallMCPData`+投影 `{name,nick,role,openDingtalkId}` |
| `calendar +free` | 吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套 | 投影为 `{who,userId,free,busy:[{start,end}]}` |
| `calendar +today` | 吐 17 字段冗长事件(含完整 attendees 数组),与 `+week` 不一致 | 投影为 `{title,start,end,location,eventId}`,对齐 `+week` |
| `calendar +suggest-time` | `timeConflictAttendees:[null]` 噪音 + result 包裹 | 拍平 + 丢 null 冲突 → `{suggestions:[{start,end}]}` |
**后端受限、无法真机正向验证的(非代码问题)**:`chat +chat-messages`/`+search-msg`(读会话消息需更高 PAT 权限 / org 未开 CLI 数据访问)、`minutes +*`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)、`contact +team`(列部门成员 medium-risk 权限墙)。这些命令的**组装链路**经 assemble 测试验证正确,仅无法在本环境跑通后端。
**一个已澄清的非 bug**:`resolveUser` 对组织内成员(如董鑫阳→userId 202397)真机端到端正常;对外部/资料受限联系人 `search_contact_by_key_word` 只返回 openDingTalkId(name/userId 全 null),此时正确报「没找到」而非瞎猜——钉钉数据模型现实,非代码缺陷。
> **结论**:真机验证证明「防御式多候选 key 投影」在真实响应上整体成立,并纠正了 8 处「合成测试盖不住」的解析/投影偏差。这是把可用性从「组装正确」提升到「真机输出正确」的关键一环。
---
## 3. 效果评估 GSB(vs lark-cli)
以 lark-cli 各服务领域为基准,评估 dws shortcut 的相对表现。**G**ood=优于/更全,**S**ame=持平,**B**ad=弱于/缺失。
> ⚠️ 重要前提:两边是**不同 API**(飞书 vs 钉钉),数量不能机械 1:1;覆盖度受钉钉实际能力约束。
>
> **口径(prune 后重做)**:不再用 shortcut 数(会因去冗余失真),改用**「dws 该服务实际暴露的 distinct MCP tool 数」= helper 命令 ∪ shortcut 覆盖的 tool 合集**——这才代表真实能力,与 helper/shortcut 怎么分层无关。对比 lark 的 shortcut 数(不同 API,只作量级参考)。
| lark 服务 | lark 数 | dws 对应 | dws tool 覆盖 | (其中 shortcut 补) | GSB | 说明 |
|-----------|:---:|---------|:---:|:---:|:---:|------|
| im | 21 | chat | **95** | +3 | 🟢 G | 群/消息/机器人能力更全 |
| mail | 21 | mail | **43** | +0 | 🟢 G | 覆盖更广(几乎全由 helper 提供,shortcut 曾重复、已 prune) |
| doc | 14 | doc | **34** | +4 | 🟢 G | 块级读写更细 |
| minutes | 14 | minutes | **25** | +0 | 🟢 G | 录音控制/说话人更全 |
| calendar | 12 | calendar | **24** | +5 | 🟢 G | 会议室/ACL/忙闲;shortcut 另补排期智能 |
| contact | 2 | contact | **15** | +0 | 🟢 G | 部门/角色/花名册更全 |
| wiki | 12 | wiki | **16** | +0 | 🟢 G | 略优 |
| base | 87 | aitable | **79** | **+63** | 🟡 S | 基本持平;**helper 仅 16,shortcut 层补齐了绝大部分**(真·gap-fill) |
| task | 18 | todo | **20** | +1 | 🟡 S | 持平 |
| drive | 26 | drive | **25** | +4 | 🟡 S | 基本持平 |
| apps | 63 | devapp | **25** | **+25** | 🔴 B | **helper 无 devapp 命令、25 个全由 shortcut 提供**(纯 gap-fill),但仍少于 lark |
| sheets | 84 | sheet | **60** | +1 | 🔴 B | 钉钉表格 MCP 较少;helper 已覆盖,shortcut 曾重复、已 prune |
| vc | 18 | — | 0 | — | 🔴 B | 钉钉 conference 无干净 MCP tool |
| okr | 13 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
| slides / markdown / whiteboard / note / event | 17 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
**dws 独有(lark 无对应服务)**:oa 审批(~20 tool) · attendance 考勤(~38) · report 日志(~7) · ding(~8) · aisearch/live/devdoc(helper 层提供)——钉钉工作流核心,构成差异化。
### GSB 汇总
- 🟢 **G(7 领域)**:im/mail/doc/minutes/calendar/contact/wiki——dws tool 覆盖更全。
- 🟡 **S(3 领域)**:base/task/drive 量级持平(base 的持平**几乎全靠 shortcut 层 gap-fill**,helper 只有 16)。
- 🔴 **B(受限 2 + 不可对齐 6)**:apps/sheets 少于 lark(sheets 受钉钉 API 限,apps 全由 shortcut 补但仍少);vc/okr/slides/markdown/whiteboard/note/event 客观不可对齐(非遗漏)。
- **注**:这张表也印证了 1:1 层的真实价值分布——**base/apps 靠 shortcut 补了大量 helper 没有的 tool(gap-fill),而 mail/sheets 的 shortcut 基本是重复 helper(已 prune)**。
---
## 4. dws 差异化于 lark 的优势
### 4.1 执行底座:复用生产级 MCP 通道(架构性优势)
lark-cli 每个 shortcut 直连飞书 SDK,错误处理/输出各自实现。dws shortcut 的 `CallMCP` **委托统一的 MCP 调用路径**,一步继承:
- **错误分类**:auth 过期 / 未登录 / PAT / 业务错误,自动给出可执行提示;
- **`--dry-run` 预览**:不发网络,输出将执行的 tool + 参数;
- **`--format`/`--jq`/`--fields`**:机器可解析输出,Agent 友好。
lark 需在每个命令重复实现这些;dws 由框架统一注入,一致性与维护成本双赢。
### 4.2 覆盖更全的高频协作能力
按 **dws tool 覆盖**(helper∪shortcut,§3 口径):chat 95 / mail 43 / doc 34 / minutes 25 / calendar 24 等即时协作场景,多为 lark 对应服务的 2–4 倍。
### 4.3 钉钉原生特有能力(lark 完全没有)
审批 oa、日志 report、考勤 attendance、DING、企业智能搜索 aisearch —— 这些是钉钉工作流的核心,构成差异化护城河。
### 4.4 高频自动沉淀(P2,lark 无此设计)
基于用户高频使用**主动把常用操作沉淀为自定义 shortcut**(`~/.dws/shortcuts/*.yaml` 运行时加载),让 CLI 越用越顺手。埋点**默认关(opt-in,`DWS_USAGE_TRACKING=1` 开启)**+开启后首跑告知,隐私优先(记形状不记值)。详见 [P2 设计](shortcut-p2-design.md)。**lark-cli 无任何等价能力。**
### 4.5 AI Agent 友好
`--yes` 跳过确认、结构化错误、`--dry-run` 预览、`--print-schema`(规划中)——为 Agent 自动化调用而设计。
### 4.6 工程质量:全量自动化测试
假 Caller 拦截,对全部 366 条(含写/删)做零副作用验证 + tool 名 ground truth 核验,可复跑、可回归。
---
## 5. 复盘与后续(loop 持续项)
### 5.0 关键复盘:1:1 层去冗余(511 → 298)
**问题**:建 1:1 shortcut 层之前,**没有先摸清 `internal/helpers/` 已经把哪些 MCP tool 封装成了 `dws <svc> <verb>` 产品命令**。结果对 **213 个 tool 重复封装**——同一个 tool,helper 有 `dws contact user search`、我又造了 `dws contact +search-user`,且这批无投影增量,纯重复。
**纠偏**:按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 213 条,1:1 层 511 → **298**(保留 233 填空白 + 65 有投影),总数 579 → **366**,服务 19 → **16**(aisearch/live/devdoc 整包移除)。全绿、真机复验保留命令仍可用。
**教训**:**做封装层前先审已有封装**。对齐 lark「每一条 shortcut」时,应先问「dws 这边是不是已经有等价命令了」,而不是无脑对齐。这是本项目最大的方法论盲点。
1. ~~补 apps(↔devapp)~~ ✅ 已完成:新增 30 个 devapp shortcut。
2. **P2 落地**(差异化能力,lark 无):
- ✅ **P2-1 usage 埋点**:装饰 MCP 调用唯一必经点记录 `~/.dws/usage.jsonl`(**记形状不记值**,敏感/自由文本字段脱敏;**默认关/opt-in**,`DWS_USAGE_TRACKING=1` 开启、开启后首跑告知)。端到端验证通过。
- ✅ **stats/list**:`dws shortcut list [--service]`、`dws shortcut stats [--top N] [--purge]`(按 `(product,tool,arg_keys)` 聚合、识别固定值 fixed_args)。
- ✅ **P2-2 suggest**:`dws shortcut suggest [--min N]` 把高频分组转成「建议沉淀的 +command」候选(含固定/可变参数拆分)。
- ✅ **P2-3 YAML 自定义 shortcut 沉淀闭环**:`dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 下次运行 `userdef.Load()` 编译成 Shortcut 注册(复用同一 runner;`${flag}` 绑定+常量;与内建冲突自动跳过)。**端到端验证通过**:add→重载→`dws <svc> +<cmd>` 可用,dry-run 组装正确。
- ⏳ **P2-4 nudge**:命中高频候选时主动提示(TTY、频控、可永久关闭)。
> 至此,**「高频使用 → 建议 → 一键沉淀 → 自定义 shortcut 运行时生效」完整闭环已打通**——这是 lark-cli 完全没有的差异化能力。
3. **深化 sheets**:随钉钉表格 MCP 能力增强补齐。
4. **实机全读回归**:登录态下对全部只读 shortcut 做真实调用回归(需有效 token + 真实资源 ID)。
5. **不可对齐项归档**:okr/slides/whiteboard/note/vc/event 明确标注为钉钉无能力,避免误解为遗漏。
---
## 附:一句话结论
> dws 已把钉钉侧**能对齐的主要服务全部对齐**(16 服务 / **366 shortcut** = 298 封装 + 68 智能编排),在即时协作能力上显著优于 lark,并拥有审批/考勤/日志/DING 等钉钉原生差异化能力与「高频自动沉淀」独有设计;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量自动化验证。
-95
View File
@@ -1,95 +0,0 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
-74
View File
@@ -1,74 +0,0 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
-108
View File
@@ -1,108 +0,0 @@
# DWS 统一命令框架设计概要
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
## 1. 产品裁决
1. 不公开 `--output-contract`,也不增加任何等价别名。
2. Agent 继续只使用既有 `--format json`。
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
5. 回滚是命令声明与发布行为,不改变消费者 argv。
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
## 2. 渐进迁移
内部状态机:
```text
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
```
- `legacy_only`:只构造、输出 legacy。
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
- `unified_only`:清理仅服务 legacy 的产品 renderer。
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
## 3. 统一结果
统一命令框架表达四类结果:
```text
success 请求完成且命令认为操作已完成
pending 请求被受理,但异步操作尚未终结
partial_failure 批量操作有成功项,也有失败或未知项
failure 请求或操作失败
```
JSON 基本形态:
```json
{
"ok": true,
"outcome": "success",
"data": {}
}
```
硬不变量:
```text
ok == (outcome in {success, pending})
process rc == 0 <=> ok == true
top-level error present <=> outcome == failure
one invocation emits exactly one primary result
```
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
## 4. 输出与错误纪律
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
自己声明专用流协议。
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
混入分页控制字段。
- 日志不得污染 stdout。
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
## 5. 重试与超时边界
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
## 6. 集成范围
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
## 7. 对齐原则
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
## 8. 发布门禁
命令晋级 `unified_active` 前至少满足:
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
4. Help、Schema 和全仓示例不存在协议选择参数。
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
+2 -32
View File
@@ -1,20 +1,11 @@
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.9
go 1.25.8
require (
github.com/Microsoft/go-winio v0.6.2
github.com/RealAlexandreAI/json-repair v0.0.15
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -23,32 +14,11 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
)
require (
-79
View File
@@ -1,99 +1,27 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -103,18 +31,11 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-230
View File
@@ -1,230 +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 apiclient provides a lightweight HTTP client for calling DingTalk
// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC
// transport. It is used exclusively by the `dws api` command.
package apiclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const (
// DefaultBaseURL is the DingTalk new-style OpenAPI base URL.
DefaultBaseURL = "https://api.dingtalk.com"
// LegacyBaseURL is the DingTalk legacy (oapi) API base URL.
LegacyBaseURL = "https://oapi.dingtalk.com"
// AuthHeader is the new-style OpenAPI authentication header.
AuthHeader = "x-acs-dingtalk-access-token"
// LegacyAuthParam is the query parameter used for legacy API authentication.
LegacyAuthParam = "access_token"
)
// AllowedMethods is the set of HTTP methods permitted for raw API calls.
var AllowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
var newHTTPRequest = http.NewRequestWithContext
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
type RawAPIRequest struct {
Method string // GET, POST, PUT, PATCH, DELETE
Path string // /v1.0/calendar/events or full URL
Params map[string]any // query parameters
Data any // request body (JSON), nil for GET
}
// RawAPIResponse encapsulates the raw HTTP response.
type RawAPIResponse struct {
StatusCode int
Header http.Header
Body []byte
}
// APIClient wraps an HTTP client for DingTalk OpenAPI calls.
type APIClient struct {
BaseURL string
HTTPClient *http.Client
Token string
}
// NewClient creates an APIClient with sensible defaults.
func NewClient(token, baseURL string) *APIClient {
if strings.TrimSpace(baseURL) == "" {
baseURL = DefaultBaseURL
}
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTPClient: &http.Client{
Transport: defaultTransport(),
Timeout: 30 * time.Second,
},
}
}
// Do sends a raw API request and returns the response.
func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) {
method := strings.ToUpper(strings.TrimSpace(req.Method))
if !AllowedMethods[method] {
return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method)
}
fullURL, err := c.buildURL(req.Path, req.Params)
if err != nil {
return nil, fmt.Errorf("building request URL: %w", err)
}
// Security: verify target host before sending token.
if err := ValidateTargetHost(fullURL); err != nil {
return nil, err
}
var bodyReader io.Reader
if req.Data != nil && method != "GET" {
data, marshalErr := json.Marshal(req.Data)
if marshalErr != nil {
return nil, fmt.Errorf("marshaling request body: %w", marshalErr)
}
bodyReader = bytes.NewReader(data)
}
httpReq, err := newHTTPRequest(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
// Set headers and auth based on API style.
if IsLegacyAPI(fullURL) {
// Legacy API: token goes in query parameter.
parsed, _ := url.Parse(fullURL)
q := parsed.Query()
q.Set(LegacyAuthParam, c.Token)
parsed.RawQuery = q.Encode()
httpReq.URL = parsed
} else {
// New API: token goes in header.
httpReq.Header.Set(AuthHeader, c.Token)
}
if bodyReader != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("User-Agent", "dws-cli/raw-api")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("executing HTTP request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
return &RawAPIResponse{
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: body,
}, nil
}
// buildURL constructs the full request URL from path and query params.
func (c *APIClient) buildURL(path string, params map[string]any) (string, error) {
normalised := NormalisePath(path, c.BaseURL)
parsed, err := url.Parse(normalised)
if err != nil {
return "", fmt.Errorf("parsing URL %q: %w", normalised, err)
}
if len(params) > 0 {
q := parsed.Query()
for k, v := range params {
q.Set(k, fmt.Sprintf("%v", v))
}
parsed.RawQuery = q.Encode()
}
return parsed.String(), nil
}
// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint.
// Legacy APIs use query-parameter authentication instead of header-based auth.
func IsLegacyAPI(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "oapi.dingtalk.com") ||
strings.HasPrefix(lower, LegacyBaseURL)
}
// NormalisePath normalises an API path:
// - Full URLs are accepted as-is (after stripping query/fragment)
// - Relative paths are prefixed with the base URL
// - Query strings and fragments are stripped (must use --params)
func NormalisePath(path, baseURL string) string {
path = strings.TrimSpace(path)
// Strip query and fragment to force --params usage.
if idx := strings.IndexAny(path, "?#"); idx >= 0 {
path = path[:idx]
}
// Full URL: extract the path portion relative to the base.
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
// Ensure leading slash.
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.TrimSpace(baseURL) == "" {
baseURL = DefaultBaseURL
}
return strings.TrimRight(baseURL, "/") + path
}
// defaultTransport returns a tuned http.Transport matching the project conventions.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 20 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
}
-326
View File
@@ -1,326 +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 apiclient
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestNewClient_DefaultBaseURL(t *testing.T) {
c := NewClient("tok", "")
if c.BaseURL != DefaultBaseURL {
t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL)
}
}
func TestNewClient_CustomBaseURL(t *testing.T) {
c := NewClient("tok", "https://custom.api.com/")
if c.BaseURL != "https://custom.api.com" {
t.Errorf("expected trailing slash stripped, got %q", c.BaseURL)
}
}
func TestNormalisePath(t *testing.T) {
tests := []struct {
path, base, want string
}{
{"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestDo_Success(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(AuthHeader) != "test-token" {
t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader))
}
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]string{"name": "test"})
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_PostWithBody(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected JSON content type")
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["key"] != "value" {
t.Errorf("expected body key=value, got %v", body)
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "/v1.0/test",
Data: map[string]string{"key": "value"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_InvalidMethod(t *testing.T) {
c := NewClient("tok", "")
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "INVALID",
Path: "/test",
})
if err == nil {
t.Error("expected error for invalid method")
}
}
func TestDo_QueryParams(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("pageSize") != "10" {
t.Errorf("expected pageSize=10, got %v", r.URL.Query())
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
Params: map[string]any{"pageSize": 10},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIsLegacyAPI(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://api.dingtalk.com/v1.0/users", false},
{"https://oapi.dingtalk.com/topapi/v2/user/get", true},
{"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true},
{"https://custom.example.com/api", false},
{"", false},
}
for _, tt := range tests {
got := IsLegacyAPI(tt.url)
if got != tt.want {
t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want)
}
}
}
func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Legacy API: token should be in query param.
if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" {
t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query())
}
// Should NOT have the new-style auth header.
if r.Header.Get(AuthHeader) != "" {
t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`))
}))
defer srv.Close()
// Use full URL with oapi.dingtalk.com in the path, but redirect to test server.
// Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL
// and pass the full oapi URL as Path so that NormalisePath preserves it.
// Then we override the resolved URL in the client to point to our test server.
//
// Best approach: directly verify that buildURL + IsLegacyAPI routing works
// by testing buildURL output and calling Do with a custom transport that
// redirects oapi.dingtalk.com to our test server.
c := NewClient("legacy-token", "")
// Replace the transport to redirect oapi.dingtalk.com to test server.
c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL}
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "https://oapi.dingtalk.com/topapi/v2/user/get",
Data: map[string]string{"userid": "user1"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server.
type legacyTestTransport struct {
targetURL string
}
func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the host to point to our test server, preserving path and query.
newURL := t.targetURL + req.URL.Path
if req.URL.RawQuery != "" {
newURL += "?" + req.URL.RawQuery
}
parsed, _ := url.Parse(newURL)
req.URL = parsed
req.Host = parsed.Host
return http.DefaultTransport.RoundTrip(req)
}
func TestNormalisePath_Legacy(t *testing.T) {
tests := []struct {
path, base, want string
}{
// Legacy full URL preserved.
{"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Relative path with legacy base URL.
{"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Strip query from legacy URL.
{"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestResolvePageLimit(t *testing.T) {
t.Parallel()
tests := []struct {
raw, want int
}{
// 0 → unlimited → safety cap
{0, MaxPageLimit},
// normal usage
{3, 3},
// default
{10, 10},
// within cap
{100, 100},
// exactly cap
{MaxPageLimit, MaxPageLimit},
// exceeds cap
{MaxPageLimit + 100, MaxPageLimit},
// negative → default
{-1, DefaultPageLimit},
{-100, DefaultPageLimit},
}
for _, tt := range tests {
got := resolvePageLimit(tt.raw)
if got != tt.want {
t.Errorf("resolvePageLimit(%d) = %d, want %d", tt.raw, got, tt.want)
}
}
}
func TestPaginateAll_ProgressLog(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount >= 3 {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{"has_more": false, "items": []any{1, 2}},
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"has_more": true,
"next_cursor": 100,
"items": []any{callCount},
},
})
}
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
var logBuf bytes.Buffer
pages, err := c.PaginateAll(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
}, PaginationOptions{
PageLimit: 5,
PageDelay: 0,
LogWriter: &logBuf,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pages) != 3 {
t.Errorf("expected 3 pages, got %d", len(pages))
}
log := logBuf.String()
if !strings.Contains(log, "第 1 页") || !strings.Contains(log, "第 2 页") || !strings.Contains(log, "第 3 页") {
t.Errorf("expected progress log for each page, got: %s", log)
}
if !strings.Contains(log, "数据获取完成") {
t.Errorf("expected completion message, got: %s", log)
}
}
-293
View File
@@ -1,293 +0,0 @@
package apiclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
type failingReader struct{ err error }
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestCrossPlatformCoverageDryRunAndParseCoverageEdges(t *testing.T) {
for _, tc := range []struct {
base string
path string
}{
{DefaultBaseURL, "/v1.0/test"},
{LegacyBaseURL, "/topapi/test"},
} {
var out bytes.Buffer
err := PrintDryRun(&out, RawAPIRequest{
Method: "post", Path: tc.path,
Params: map[string]any{"page": 1}, Data: map[string]any{"name": "value"},
}, tc.base, "token-value")
if err != nil || !strings.Contains(out.String(), "Dry Run") || !strings.Contains(out.String(), "toke****") {
t.Fatalf("PrintDryRun(%s) = %q, %v", tc.base, out.String(), err)
}
}
var out bytes.Buffer
if err := PrintDryRun(&out, RawAPIRequest{Method: "get", Path: "/x", Params: map[string]any{"bad": make(chan int)}, Data: make(chan int)}, DefaultBaseURL, "tiny"); err != nil {
t.Fatalf("PrintDryRun unsupported preview: %v", err)
}
wantErr := errors.New("read failed")
if _, err := ParseJSONMap("-", "--params", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("ParseJSONMap read error = %v", err)
}
if got, err := ParseJSONMap("-", "--params", strings.NewReader(" \n")); err != nil || got != nil {
t.Fatalf("ParseJSONMap empty stdin = %#v, %v", got, err)
}
if _, err := ParseOptionalBody("POST", "-", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("ParseOptionalBody read error = %v", err)
}
if got, err := ParseOptionalBody("POST", "-", strings.NewReader(" \n")); err != nil || got != nil {
t.Fatalf("ParseOptionalBody empty stdin = %#v, %v", got, err)
}
if _, err := ParseOptionalBody("POST", "{", strings.NewReader("")); err == nil {
t.Fatal("invalid optional body should fail")
}
}
func TestCrossPlatformCoverageResponseHandlingCoverageEdges(t *testing.T) {
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
textHeader := http.Header{"Content-Type": []string{"text/plain"}}
var out, errOut bytes.Buffer
opts := ResponseOptions{Format: output.FormatJSON, Out: &out, ErrOut: &errOut}
if err := HandleResponse(&RawAPIResponse{StatusCode: 500, Header: textHeader, Body: []byte(" failed ")}, opts); err == nil {
t.Fatal("plain HTTP error should fail")
}
for _, body := range [][]byte{nil, []byte("{")} {
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: body}, opts); err == nil {
t.Errorf("invalid JSON body %q should fail", body)
}
}
out.Reset()
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(`{"ok":true}`)}, opts); err != nil || !strings.Contains(out.String(), "ok") {
t.Fatalf("successful JSON response = %q, %v", out.String(), err)
}
for _, payload := range []string{
`{"errcode":1}`,
`{"message":"message failure"}`,
`{"error":"error failure"}`,
`{}`,
} {
status := 200
if !strings.Contains(payload, "errcode") {
status = 500
}
if err := HandleResponse(&RawAPIResponse{StatusCode: status, Header: jsonHeader, Body: []byte(payload)}, opts); err == nil {
t.Errorf("business/HTTP payload %s should fail", payload)
}
}
if err := checkDingTalkError([]any{1}, 200); err != nil || checkDingTalkError(map[string]any{"errcode": 0}, 200) != nil {
t.Fatal("successful DingTalk response classified as error")
}
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: textHeader, Body: []byte("binary")}, opts); err == nil {
t.Fatal("binary response without filename should fail")
}
invalidCD := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="unterminated`}}
if inferFilename(invalidCD) != "" {
t.Fatal("invalid content disposition should not infer filename")
}
if inferFilename(http.Header{}) != "" {
t.Fatal("missing content disposition should not infer filename")
}
dir := t.TempDir()
blockedParent := filepath.Join(dir, "file")
if err := os.WriteFile(blockedParent, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
opts.OutputPath = filepath.Join(blockedParent, "child.bin")
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
t.Fatal("binary mkdir failure should fail")
}
opts.OutputPath = dir
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
t.Fatal("binary write to directory should fail")
}
opts.OutputPath = ""
inferred := filepath.Join(dir, "inferred.bin")
header := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="` + inferred + `"`}}
if err := handleBinaryResponse(&RawAPIResponse{Header: header, Body: []byte("bytes")}, opts); err != nil {
t.Fatalf("inferred binary save: %v", err)
}
if !strings.Contains(errOut.String(), "已保存") {
t.Fatalf("binary status = %q", errOut.String())
}
for _, ct := range []string{" application/json; charset=utf-8 ", "text/json", "application/problem+json", "text/plain"} {
_ = isJSONContentType(ct)
}
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), json.Number("bad"), "5"} {
_ = toFloat64(value)
}
}
func TestCrossPlatformCoveragePaginationParsingAndInjectionEdges(t *testing.T) {
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
for _, resp := range []*RawAPIResponse{
{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: []byte("x")},
{StatusCode: 200, Header: jsonHeader},
{StatusCode: 200, Header: jsonHeader, Body: []byte("{")},
{StatusCode: 500, Header: jsonHeader, Body: []byte(`{"message":"bad"}`)},
} {
if _, _, _, err := parsePaginatedResponse(resp); err == nil {
t.Errorf("parsePaginatedResponse(%#v) should fail", resp)
}
}
responses := []struct {
body string
more bool
token string
}{
{`{"result":{"has_more":true,"next_cursor":12}}`, true, "12"},
{`{"has_more":true,"next_cursor":13}`, true, "13"},
{`{"next_token":"next"}`, true, "next"},
{`{"result":[],"has_more":false}`, false, ""},
}
for _, tc := range responses {
_, more, token, err := parsePaginatedResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(tc.body)})
if err != nil || more != tc.more || token != tc.token {
t.Errorf("pagination %s = %v, %q, %v", tc.body, more, token, err)
}
}
getCases := []RawAPIRequest{
{Method: "GET"},
{Method: "GET", Params: map[string]any{"cursor": "old"}},
{Method: "GET", Params: map[string]any{"next_token": "old"}},
{Method: "POST", Data: map[string]any{"cursor": "old"}},
{Method: "PUT", Data: map[string]any{}},
{Method: "POST", Data: "not-a-map"},
}
for _, req := range getCases {
_ = injectPageToken(req, "new")
}
logf(nil, "ignored")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func jsonHTTPResponse(body string) *http.Response {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
}
func TestCrossPlatformCoveragePaginationControlFlowEdges(t *testing.T) {
wantErr := errors.New("transport failed")
client := NewClient("token", DefaultBaseURL)
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
})
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); err == nil {
t.Fatal("first page parse error should fail")
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); !errors.Is(err, wantErr) {
t.Fatalf("first page transport error = %v", err)
}
calls := 0
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
}
return nil, wantErr
})
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1}); err == nil || len(pages) != 1 {
t.Fatalf("later transport error pages=%d err=%v", len(pages), err)
}
calls = 0
var logs bytes.Buffer
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
})
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "解析失败") {
t.Fatalf("later parse failure pages=%d logs=%q err=%v", len(pages), logs.String(), err)
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return jsonHTTPResponse(`{"next_token":"next"}`), nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if pages, err := client.PaginateAll(ctx, RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 10}); !errors.Is(err, context.Canceled) || len(pages) != 1 {
t.Fatalf("pagination cancellation pages=%d err=%v", len(pages), err)
}
logs.Reset()
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageLimit: 1, PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "安全上限") {
t.Fatalf("pagination safety cap pages=%d logs=%q err=%v", len(pages), logs.String(), err)
}
}
func TestCrossPlatformCoverageClientAndValidationFailureEdges(t *testing.T) {
client := NewClient("token", DefaultBaseURL)
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://api.dingtalk.com/%zz"}); err == nil {
t.Fatal("Do with invalid URL should fail")
}
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://example.test/x"}); err == nil {
t.Fatal("Do to untrusted host should fail")
}
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "POST", Path: "/x", Data: make(chan int)}); err == nil {
t.Fatal("unmarshalable request body should fail")
}
if _, err := client.buildURL("https://api.dingtalk.com/%zz", nil); err == nil {
t.Fatal("invalid URL should fail")
}
oldNewRequest := newHTTPRequest
t.Cleanup(func() { newHTTPRequest = oldNewRequest })
wantCreateErr := errors.New("request creation failed")
newHTTPRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, wantCreateErr }
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantCreateErr) {
t.Fatalf("request creation error = %v", err)
}
newHTTPRequest = oldNewRequest
wantErr := errors.New("request failed")
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
t.Fatalf("HTTP transport error = %v", err)
}
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(failingReader{err: wantErr})}, nil
})
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
t.Fatalf("response read error = %v", err)
}
if ValidateTargetHost("http://%zz") == nil {
t.Fatal("invalid target URL should fail")
}
for _, r := range []rune{0x200B, 0xFEFF, 0x202A, 0x2028, 0x2066, 0x061C, 0xFDD0} {
if !isDangerousUnicode(r) || ValidateUserInput("x"+string(r), "field") == nil {
t.Errorf("dangerous rune %U was accepted", r)
}
}
if isDangerousUnicode('中') || ValidateUserInput("safe\t\n中文", "field") != nil {
t.Fatal("safe Unicode/input was rejected")
}
}
-63
View File
@@ -1,63 +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 apiclient
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// MaskToken returns a masked version of a token for display in dry-run
// and log output. Shows the first 4 characters followed by "****".
func MaskToken(token string) string {
if len(token) <= 4 {
return "****"
}
return token[:4] + "****"
}
// PrintDryRun outputs a dry-run preview of the API request that would be sent.
func PrintDryRun(w io.Writer, req RawAPIRequest, baseURL, token string) error {
fullURL := NormalisePath(req.Path, baseURL)
fmt.Fprintln(w, "=== Dry Run ===")
fmt.Fprintf(w, "%-12s%s\n", "Method:", strings.ToUpper(req.Method))
fmt.Fprintf(w, "%-12s%s\n", "URL:", fullURL)
if len(req.Params) > 0 {
paramsJSON, err := json.MarshalIndent(req.Params, " ", " ")
if err == nil {
fmt.Fprintf(w, "%-12s%s\n", "Params:", string(paramsJSON))
}
}
if req.Data != nil {
dataJSON, err := json.MarshalIndent(req.Data, " ", " ")
if err == nil {
fmt.Fprintf(w, "%-12s%s\n", "Body:", string(dataJSON))
}
}
if IsLegacyAPI(fullURL) {
fmt.Fprintf(w, "%-12s%s=%s\n", "Auth:", LegacyAuthParam, MaskToken(token))
fmt.Fprintf(w, "%-12s%s\n", "Style:", "旧版 (oapi.dingtalk.com)")
} else {
fmt.Fprintf(w, "%-12s%s: %s\n", "Auth:", AuthHeader, MaskToken(token))
fmt.Fprintf(w, "%-12s%s\n", "Style:", "新版 (api.dingtalk.com)")
}
fmt.Fprintln(w, "===============")
return nil
}
-226
View File
@@ -1,226 +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 apiclient
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
)
const (
// DefaultPageLimit is the maximum number of pages fetched with --page-all
// when --page-limit is not explicitly set.
DefaultPageLimit = 10
// MaxPageLimit is the hard safety cap to prevent infinite loops when an
// API endpoint has a bug that causes has_more to never become false.
// Use --page-limit 0 to hit this cap; any explicit positive value is
// honoured up to this ceiling.
MaxPageLimit = 500
// DefaultPageDelay is the delay between paginated requests in milliseconds.
DefaultPageDelay = 200
)
// PaginationOptions controls automatic pagination behaviour.
type PaginationOptions struct {
PageLimit int // Maximum pages (0 = unlimited, capped at MaxPageLimit)
PageDelay int // Delay between pages in milliseconds
LogWriter io.Writer // Optional: progress log output (typically stderr)
}
// PaginateAll fetches all pages of a paginated API and merges the results.
// DingTalk APIs use two pagination patterns:
// - cursor/next_cursor/has_more (in response body)
// - next_token (in response body)
//
// The function auto-detects which pattern the API uses.
func (c *APIClient) PaginateAll(ctx context.Context, req RawAPIRequest, opts PaginationOptions) ([]any, error) {
limit := resolvePageLimit(opts.PageLimit)
if opts.PageDelay <= 0 {
opts.PageDelay = DefaultPageDelay
}
var allResults []any
pageCount := 0
for {
pageCount++
// Safety cap — only break if a carry is active (pageCount > 1).
if limit > 0 && pageCount > limit {
logf(opts.LogWriter, "[pagination] ⚠ 已达安全上限 %d 页,停止翻页。数据可能不完整,请检查 API 是否异常。\n", limit)
break
}
logf(opts.LogWriter, "[pagination] 第 %d 页 请求中...\n", pageCount)
resp, err := c.Do(ctx, req)
if err != nil {
if pageCount == 1 {
return nil, err
}
// Non-first page error: return what we have so far.
return allResults, fmt.Errorf("分页第 %d 页请求失败 (已获取 %d 页结果): %w", pageCount, pageCount-1, err)
}
result, hasMore, nextToken, parseErr := parsePaginatedResponse(resp)
if parseErr != nil {
if pageCount == 1 {
return nil, parseErr
}
// Non-first page parse failure: warn the caller so users aren't
// silently left with incomplete data.
logf(opts.LogWriter, "[pagination] ⚠ 第 %d 页解析失败,停止翻页并返回已获取的 %d 页数据: %v\n", pageCount, pageCount-1, parseErr)
return allResults, nil
}
allResults = append(allResults, result)
if !hasMore || nextToken == "" {
logf(opts.LogWriter, "[pagination] 数据获取完成 (共 %d 页)\n", pageCount)
break
}
// Inject the next page token into the request.
req = injectPageToken(req, nextToken)
// Delay between pages to prevent API throttling.
select {
case <-ctx.Done():
return allResults, ctx.Err()
case <-time.After(time.Duration(opts.PageDelay) * time.Millisecond):
}
}
return allResults, nil
}
// resolvePageLimit translates the user-facing value into an internal limit:
//
// 0 → MaxPageLimit (user wants unlimited; safety cap applies)
// positive N → min(N, MaxPageLimit) (explicit page limit, still capped)
// negative → DefaultPageLimit (invalid input treated as default)
func resolvePageLimit(raw int) int {
if raw == 0 {
return MaxPageLimit
}
if raw < 0 {
return DefaultPageLimit
}
if raw > MaxPageLimit {
return MaxPageLimit
}
return raw
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format, args...)
}
// parsePaginatedResponse extracts the response payload and pagination info.
// It auto-detects DingTalk's two pagination patterns.
func parsePaginatedResponse(resp *RawAPIResponse) (result any, hasMore bool, nextToken string, err error) {
contentType := resp.Header.Get("Content-Type")
if !isJSONContentType(contentType) {
return nil, false, "", fmt.Errorf("分页响应非 JSON 格式 (Content-Type: %s)", contentType)
}
if len(resp.Body) == 0 {
return nil, false, "", fmt.Errorf("分页响应体为空 (HTTP %d)", resp.StatusCode)
}
var payload map[string]any
if unmarshalErr := jsonUnmarshal(resp.Body, &payload); unmarshalErr != nil {
return nil, false, "", fmt.Errorf("解析分页 JSON 响应失败: %w", unmarshalErr)
}
// Check for DingTalk errors first.
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
return nil, false, "", apiErr
}
// Pattern 1: cursor/next_cursor/has_more (often nested in "result" or top-level)
if resultObj, ok := payload["result"]; ok {
if resultMap, isMap := resultObj.(map[string]any); isMap {
hasMore, _ = resultMap["has_more"].(bool)
if nc, ok := resultMap["next_cursor"].(float64); ok && nc > 0 {
nextToken = fmt.Sprintf("%.0f", nc)
}
return payload, hasMore, nextToken, nil
}
}
// Top-level has_more / next_cursor
if hm, ok := payload["has_more"]; ok {
hasMore, _ = hm.(bool)
}
if nc, ok := payload["next_cursor"]; ok {
if ncf, isFloat := nc.(float64); isFloat && ncf > 0 {
nextToken = fmt.Sprintf("%.0f", ncf)
}
}
// Pattern 2: next_token
if nt, ok := payload["next_token"]; ok {
if nts, isStr := nt.(string); isStr && nts != "" {
nextToken = nts
hasMore = true
}
}
return payload, hasMore, nextToken, nil
}
// injectPageToken injects the pagination token into the next request.
// For GET requests, it's added as a query param; for POST, it's in the body.
func injectPageToken(req RawAPIRequest, token string) RawAPIRequest {
method := req.Method
if method == "GET" {
if req.Params == nil {
req.Params = make(map[string]any)
}
// Try to detect which param name the API uses
if _, ok := req.Params["cursor"]; ok {
req.Params["cursor"] = token
} else if _, ok := req.Params["next_token"]; ok {
req.Params["next_token"] = token
} else {
// Default to next_token for GET requests
req.Params["next_token"] = token
}
} else {
// For POST/PUT requests, inject into the body
if bodyMap, ok := req.Data.(map[string]any); ok {
if _, hasCursor := bodyMap["cursor"]; hasCursor {
bodyMap["cursor"] = token
} else {
bodyMap["next_token"] = token
}
req.Data = bodyMap
}
}
return req
}
// jsonUnmarshal is a helper for JSON unmarshaling.
func jsonUnmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
-102
View File
@@ -1,102 +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 apiclient
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// ParseJSONMap parses a --params flag value into a map[string]any.
// Supports:
// - JSON string: '{"key":"value"}'
// - "-" to read from stdin
// - Empty string returns nil (no params)
func ParseJSONMap(raw, flagName string, stdin io.Reader) (map[string]any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if raw == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("从 stdin 读取 %s 失败: %w", flagName, err)
}
raw = strings.TrimSpace(string(data))
if raw == "" {
return nil, nil
}
}
// Strip wrapping single quotes (common shell escaping).
raw = stripSingleQuotes(raw)
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, fmt.Errorf("解析 %s JSON 失败: %w\n输入: %s", flagName, err, truncate(raw, 200))
}
return result, nil
}
// ParseOptionalBody parses a --data flag value into a request body.
// Returns nil for empty input. GET requests are not allowed to have a body.
func ParseOptionalBody(method, raw string, stdin io.Reader) (any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if strings.ToUpper(method) == "GET" && raw != "" {
return nil, fmt.Errorf("GET 请求不允许使用 --data 参数")
}
if raw == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("从 stdin 读取 --data 失败: %w", err)
}
raw = strings.TrimSpace(string(data))
if raw == "" {
return nil, nil
}
}
// Strip wrapping single quotes.
raw = stripSingleQuotes(raw)
var result any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, fmt.Errorf("解析 --data JSON 失败: %w\n输入: %s", err, truncate(raw, 200))
}
return result, nil
}
// stripSingleQuotes removes a leading and trailing single quote pair.
func stripSingleQuotes(s string) string {
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
return s[1 : len(s)-1]
}
return s
}
// truncate returns at most n characters of s, appending "..." if truncated.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
-123
View File
@@ -1,123 +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 apiclient
import (
"strings"
"testing"
)
func TestParseJSONMap_Empty(t *testing.T) {
result, err := ParseJSONMap("", "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestParseJSONMap_ValidJSON(t *testing.T) {
result, err := ParseJSONMap(`{"key":"value","num":42}`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["key"] != "value" {
t.Errorf("expected key=value, got %v", result["key"])
}
}
func TestParseJSONMap_SingleQuotes(t *testing.T) {
result, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["key"] != "value" {
t.Errorf("expected key=value, got %v", result["key"])
}
}
func TestParseJSONMap_Stdin(t *testing.T) {
stdin := strings.NewReader(`{"from":"stdin"}`)
result, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["from"] != "stdin" {
t.Errorf("expected from=stdin, got %v", result["from"])
}
}
func TestParseJSONMap_InvalidJSON(t *testing.T) {
_, err := ParseJSONMap("not json", "--params", nil)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestParseOptionalBody_Empty(t *testing.T) {
result, err := ParseOptionalBody("POST", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestParseOptionalBody_GETNotAllowed(t *testing.T) {
_, err := ParseOptionalBody("GET", `{"data":true}`, nil)
if err == nil {
t.Error("expected error for GET with body")
}
}
func TestParseOptionalBody_ValidPOST(t *testing.T) {
result, err := ParseOptionalBody("POST", `{"key":"value"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if m["key"] != "value" {
t.Errorf("expected key=value, got %v", m["key"])
}
}
func TestStripSingleQuotes(t *testing.T) {
tests := []struct{ in, want string }{
{`'hello'`, `hello`},
{`"hello"`, `"hello"`},
{`hello`, `hello`},
{`''`, ``},
{`'`, `'`},
}
for _, tt := range tests {
got := stripSingleQuotes(tt.in)
if got != tt.want {
t.Errorf("stripSingleQuotes(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTruncate(t *testing.T) {
if got := truncate("hello", 10); got != "hello" {
t.Errorf("expected hello, got %q", got)
}
if got := truncate("hello world", 5); got != "hello..." {
t.Errorf("expected hello..., got %q", got)
}
}
-43
View File
@@ -1,43 +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 apiclient
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
// a custom Transport without an explicit Proxy field silently bypasses
// HTTP_PROXY/HTTPS_PROXY.
//
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
// it, because http.ProxyFromEnvironment memoises the env on first call;
// other tests that read proxy env early would make a value-based assertion
// flaky.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
-179
View File
@@ -1,179 +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 apiclient
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// ResponseOptions controls how an API response is processed.
type ResponseOptions struct {
OutputPath string // --output file path for binary responses
Format output.Format // output format (json|table|raw)
JqExpr string // --jq expression
Fields string // --fields comma-separated field names
Out io.Writer // stdout
ErrOut io.Writer // stderr
}
// HandleResponse routes response processing based on Content-Type and status code.
func HandleResponse(resp *RawAPIResponse, opts ResponseOptions) error {
contentType := resp.Header.Get("Content-Type")
isJSON := isJSONContentType(contentType)
// HTTP error with non-JSON body: print as plain text error.
if resp.StatusCode >= 400 && !isJSON {
return fmt.Errorf("API 请求失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(resp.Body)))
}
// JSON response
if isJSON {
return handleJSONResponse(resp, opts)
}
// Binary response
return handleBinaryResponse(resp, opts)
}
// handleJSONResponse parses the JSON body, checks for DingTalk business errors,
// and writes the output using the configured format and filters.
func handleJSONResponse(resp *RawAPIResponse, opts ResponseOptions) error {
if len(resp.Body) == 0 {
return fmt.Errorf("API 返回空响应体 (HTTP %d),如需下载文件请使用 --output 参数", resp.StatusCode)
}
var payload any
if err := json.Unmarshal(resp.Body, &payload); err != nil {
return fmt.Errorf("解析 JSON 响应失败: %w", err)
}
// Check for DingTalk business error: {"errcode": xxx, "errmsg": "xxx"}
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
return apiErr
}
return output.WriteFiltered(opts.Out, opts.Format, payload, opts.Fields, opts.JqExpr)
}
// checkDingTalkError inspects a parsed JSON response for DingTalk error codes.
// Returns nil if no error is detected.
func checkDingTalkError(payload any, statusCode int) error {
obj, ok := payload.(map[string]any)
if !ok {
return nil
}
// Check for errcode != 0
if errcode, hasCode := obj["errcode"]; hasCode {
code := toFloat64(errcode)
if code != 0 {
errmsg, _ := obj["errmsg"].(string)
if errmsg == "" {
errmsg = "unknown error"
}
return fmt.Errorf("API 业务错误 (errcode: %.0f, HTTP %d): %s", code, statusCode, errmsg)
}
}
// Also check HTTP error status even if no errcode field
if statusCode >= 400 {
errmsg, _ := obj["errmsg"].(string)
if errmsg == "" {
errmsg, _ = obj["message"].(string)
}
if errmsg == "" {
errmsg, _ = obj["error"].(string)
}
if errmsg != "" {
return fmt.Errorf("API 请求失败 (HTTP %d): %s", statusCode, errmsg)
}
return fmt.Errorf("API 请求失败 (HTTP %d)", statusCode)
}
return nil
}
// handleBinaryResponse saves the response body to a file.
func handleBinaryResponse(resp *RawAPIResponse, opts ResponseOptions) error {
outputPath := strings.TrimSpace(opts.OutputPath)
if outputPath == "" {
// Try to infer filename from Content-Disposition header.
outputPath = inferFilename(resp.Header)
if outputPath == "" {
return fmt.Errorf("响应为非 JSON 格式 (Content-Type: %s),请使用 --output 指定保存路径",
resp.Header.Get("Content-Type"))
}
}
dir := filepath.Dir(outputPath)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建输出目录失败: %w", err)
}
}
if err := os.WriteFile(outputPath, resp.Body, 0o644); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
fmt.Fprintf(opts.ErrOut, "已保存到: %s (%d 字节)\n", outputPath, len(resp.Body))
return nil
}
// inferFilename tries to extract a filename from the Content-Disposition header.
func inferFilename(header http.Header) string {
cd := header.Get("Content-Disposition")
if cd == "" {
return ""
}
_, params, err := mime.ParseMediaType(cd)
if err != nil {
return ""
}
return strings.TrimSpace(params["filename"])
}
// isJSONContentType returns true if the Content-Type indicates JSON.
func isJSONContentType(ct string) bool {
ct = strings.TrimSpace(strings.ToLower(ct))
return strings.HasPrefix(ct, "application/json") ||
strings.HasPrefix(ct, "text/json") ||
strings.Contains(ct, "+json")
}
// toFloat64 attempts to convert a JSON number to float64.
func toFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case json.Number:
f, _ := n.Float64()
return f
}
return 0
}
-146
View File
@@ -1,146 +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 apiclient
import (
"fmt"
"net/url"
"strings"
)
// AllowedHosts is the set of trusted DingTalk API hosts.
// Only these hosts may receive access tokens to prevent token leakage.
var AllowedHosts = map[string]bool{
"api.dingtalk.com": true,
"oapi.dingtalk.com": true,
}
// ValidateTargetHost checks that the resolved request URL targets a trusted
// DingTalk host. This prevents access-token leakage to arbitrary domains.
func ValidateTargetHost(fullURL string) error {
parsed, err := url.Parse(fullURL)
if err != nil {
return fmt.Errorf("无法解析请求 URL: %w", err)
}
host := strings.ToLower(parsed.Hostname())
if !AllowedHosts[host] {
return fmt.Errorf(
"安全限制: 目标域名 %q 不在允许列表中。\n"+
"dws api 仅允许向以下域名发起请求:\n"+
" - api.dingtalk.com (新版 API)\n"+
" - oapi.dingtalk.com (旧版 API)\n"+
"请检查 URL 或 --base-url 参数是否正确。",
host,
)
}
return nil
}
// ValidateMethod checks that the HTTP method is one of the five allowed methods.
func ValidateMethod(method string) (string, error) {
upper := strings.ToUpper(strings.TrimSpace(method))
if !AllowedMethods[upper] {
return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method)
}
return upper, nil
}
// ValidatePath checks the API path for injection attacks and dangerous characters.
func ValidatePath(path string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("API 路径不能为空")
}
if err := rejectDangerousChars(path, "path"); err != nil {
return err
}
// Reject path traversal
if strings.Contains(path, "..") {
return fmt.Errorf("API 路径不能包含 '..' (路径遍历)")
}
return nil
}
// ValidateUserInput checks a user-provided string for control characters and
// dangerous Unicode codepoints that could enable injection attacks.
func ValidateUserInput(value, fieldName string) error {
return rejectDangerousChars(value, fieldName)
}
// rejectDangerousChars rejects C0 control characters (except \t and \n),
// DEL (0x7F), and dangerous Unicode codepoints in a string.
func rejectDangerousChars(s, fieldName string) error {
for i, r := range s {
// Allow tab and newline
if r == '\t' || r == '\n' {
continue
}
// Reject C0 control chars (0x00-0x1F) and DEL (0x7F)
if r < 0x20 || r == 0x7F {
return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r)
}
// Reject dangerous Unicode
if isDangerousUnicode(r) {
return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r)
}
}
return nil
}
// isDangerousUnicode returns true for Unicode codepoints that can be used
// for visual spoofing or terminal injection attacks.
func isDangerousUnicode(r rune) bool {
switch {
// Zero-width characters
case r >= 0x200B && r <= 0x200D:
return true
// BOM
case r == 0xFEFF:
return true
// Bidi override characters
case r >= 0x202A && r <= 0x202E:
return true
// Line/paragraph separator
case r == 0x2028 || r == 0x2029:
return true
// Bidi isolate characters
case r >= 0x2066 && r <= 0x2069:
return true
// Additional Bidi controls
case r == 0x061C:
return true
// Non-characters
case r >= 0xFDD0 && r <= 0xFDEF:
return true
}
// Object replacement (U+FFFC) / replacement (U+FFFD) characters and
// other non-printable non-ASCII runes (e.g. CJK, symbols) are allowed
// through — only the explicit dangerous ranges above are blocked.
return false
}
// ValidateStdinExclusion checks that --params and --data don't both read from stdin.
func ValidateStdinExclusion(params, data string) error {
if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" {
return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)")
}
return nil
}
// ValidateFlagExclusion checks mutual exclusion between flags.
func ValidateFlagExclusion(outputPath string, pageAll bool) error {
if strings.TrimSpace(outputPath) != "" && pageAll {
return fmt.Errorf("--output 和 --page-all 不能同时使用")
}
return nil
}
-155
View File
@@ -1,155 +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 apiclient
import (
"strings"
"testing"
)
func TestValidateMethod(t *testing.T) {
valid := []string{"GET", "get", "Post", "put", "PATCH", "delete"}
for _, m := range valid {
got, err := ValidateMethod(m)
if err != nil {
t.Errorf("ValidateMethod(%q) unexpected error: %v", m, err)
}
if got != strings.ToUpper(m) {
t.Errorf("ValidateMethod(%q) = %q, want %q", m, got, strings.ToUpper(m))
}
}
invalid := []string{"HEAD", "OPTIONS", "TRACE", "CONNECT", "INVALID", ""}
for _, m := range invalid {
_, err := ValidateMethod(m)
if err == nil {
t.Errorf("ValidateMethod(%q) expected error, got nil", m)
}
}
}
func TestValidatePath(t *testing.T) {
// Valid paths
for _, p := range []string{"/v1.0/users", "/v2.0/calendar/events", "v1.0/contact/users/me"} {
if err := ValidatePath(p); err != nil {
t.Errorf("ValidatePath(%q) unexpected error: %v", p, err)
}
}
// Empty path
if err := ValidatePath(""); err == nil {
t.Error("ValidatePath(\"\") expected error")
}
// Path traversal
if err := ValidatePath("/v1.0/../secret"); err == nil {
t.Error("ValidatePath with .. expected error")
}
// Control character
if err := ValidatePath("/v1.0/\x00test"); err == nil {
t.Error("ValidatePath with null byte expected error")
}
}
func TestRejectDangerousUnicode(t *testing.T) {
// Zero-width space
if err := ValidateUserInput("hello\u200Bworld", "test"); err == nil {
t.Error("expected error for zero-width space")
}
// BOM
if err := ValidateUserInput("\uFEFFhello", "test"); err == nil {
t.Error("expected error for BOM")
}
// Bidi override
if err := ValidateUserInput("hello\u202Aworld", "test"); err == nil {
t.Error("expected error for bidi override")
}
// Normal string should pass
if err := ValidateUserInput("hello world 你好", "test"); err != nil {
t.Errorf("unexpected error for normal string: %v", err)
}
}
func TestValidateStdinExclusion(t *testing.T) {
if err := ValidateStdinExclusion("-", "-"); err == nil {
t.Error("expected error when both params and data read from stdin")
}
if err := ValidateStdinExclusion("-", "{}"); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := ValidateStdinExclusion("{}", "-"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateFlagExclusion(t *testing.T) {
if err := ValidateFlagExclusion("output.json", true); err == nil {
t.Error("expected error when --output and --page-all both set")
}
if err := ValidateFlagExclusion("output.json", false); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := ValidateFlagExclusion("", true); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestMaskToken(t *testing.T) {
tests := []struct {
in, want string
}{
{"", "****"},
{"abc", "****"},
{"abcd", "****"},
{"abcde", "abcd****"},
{"abcdefghij", "abcd****"},
}
for _, tt := range tests {
got := MaskToken(tt.in)
if got != tt.want {
t.Errorf("MaskToken(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestValidateTargetHost(t *testing.T) {
// Allowed hosts
allowed := []string{
"https://api.dingtalk.com/v1.0/contact/users/me",
"https://oapi.dingtalk.com/topapi/v2/user/get",
"https://API.DINGTALK.COM/v1.0/test",
"https://OAPI.DINGTALK.COM/topapi/test",
}
for _, u := range allowed {
if err := ValidateTargetHost(u); err != nil {
t.Errorf("ValidateTargetHost(%q) unexpected error: %v", u, err)
}
}
// Blocked hosts
blocked := []string{
"https://oapi.dingtalk.fakedomain.com/topapi/v2/user/get",
"https://fake.com/v1.0/test",
"https://api.dingtalk.com.evil.com/v1.0/test",
"https://evil.com/redirect?url=https://api.dingtalk.com",
"http://localhost:8080/v1.0/test",
"https://dingtalk.com/v1.0/test",
}
for _, u := range blocked {
if err := ValidateTargetHost(u); err == nil {
t.Errorf("ValidateTargetHost(%q) expected error, got nil", u)
}
}
}
-223
View File
@@ -1,223 +0,0 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type tokenManagerSnapshotProvider struct {
load func() (*authpkg.TokenData, error)
}
func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) {
data, err := p.load()
if err != nil || data == nil {
return "", err
}
return data.AccessToken, nil
}
func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) {
return p.load()
}
type tokenManagerLegacyGetter struct {
token string
err error
}
func (g tokenManagerLegacyGetter) GetToken() (string, string, error) {
return g.token, "file", g.err
}
func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) {
t.Helper()
oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager
oldEdition := edition.Get()
edition.Override(&edition.Hooks{})
newAccessTokenProvider = func(string) accessTokenGetter {
return tokenManagerSnapshotProvider{load: load}
}
newLegacyTokenManager = func(string) legacyTokenGetter {
return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound}
}
t.Cleanup(func() {
newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy
edition.Override(oldEdition)
})
}
func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
token := "token-a"
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
first, err := manager.Get(context.Background(), configDir, "")
if err != nil || first.AccessToken != "token-a" {
t.Fatalf("first token = %#v, %v", first, err)
}
second, err := manager.Get(context.Background(), configDir, "")
if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 {
t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load())
}
token = "token-b"
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
rotated, err := manager.Get(context.Background(), configDir, "")
if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 {
t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) {
configDir := t.TempDir()
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token"}, nil
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
for range 2 {
if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" {
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
edition.Override(&edition.Hooks{
LoadToken: func(string) ([]byte, error) { return nil, nil },
TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
return fallback()
},
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
release := make(chan struct{})
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
<-release
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
const workers = 8
var wg sync.WaitGroup
wg.Add(workers)
errs := make(chan error, workers)
for range workers {
go func() {
defer wg.Done()
_, err := manager.Get(context.Background(), configDir, "")
errs <- err
}()
}
for calls.Load() == 0 {
time.Sleep(time.Millisecond)
}
close(release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) {
configDir := t.TempDir()
want := errors.New("keychain permission denied")
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want })
_, err := NewTokenManager().Get(context.Background(), configDir, "")
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("provider failure was misclassified as missing credentials: %v", err)
}
}
func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) {
missing := tokenResolutionError(authpkg.ErrTokenDataNotFound)
var typed interface{ Unwrap() error }
if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) {
t.Fatalf("missing error = %v", missing)
}
want := errors.New("decrypt failed")
if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) {
t.Fatalf("storage error = %v", got)
}
if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) {
t.Fatalf("cancellation = %v", got)
}
}
+37 -257
View File
@@ -21,283 +21,63 @@ import (
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const accessTokenRefreshWindow = 5 * time.Minute
type legacyTokenGetter interface {
GetToken() (string, string, error)
}
type accessTokenSnapshotGetter interface {
GetTokenSnapshot(context.Context) (*authpkg.TokenData, error)
}
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
// Refresh-token material never leaves the auth package.
type AccessTokenSnapshot struct {
AccessToken string
ExpiresAt time.Time
Source string
}
type tokenManagerKey struct {
configDir string
profile string
}
type tokenManagerEntry struct {
mu sync.Mutex
snapshot AccessTokenSnapshot
revision string
}
// TokenManager is the only process cache for user access tokens. Cache entries
// are isolated by config directory and profile, expiry-aware, and invalidated
// by the credential publication marker written by auth storage.
type TokenManager struct {
mu sync.Mutex
entries map[tokenManagerKey]*tokenManagerEntry
now func() time.Time
}
func NewTokenManager() *TokenManager {
return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now}
}
var runtimeTokenManager = NewTokenManager()
var (
newAccessTokenProvider = func(configDir string) accessTokenGetter {
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, discard)
configureOAuthProviderCompatibility(provider, configDir)
return provider
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
// the same host compatibility hooks as MCP. It mirrors the former body of
// getCachedRuntimeToken (excluding process-level cache and timing).
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
}
newLegacyTokenManager = func(configDir string) legacyTokenGetter {
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
return manager
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
return "", tokenErr
}
)
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
return strings.TrimSpace(leg), nil
}
return "", nil
}
// Get resolves an access token for the active runtime profile.
func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) {
if token := strings.TrimSpace(explicitToken); token != "" {
return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
// the active edition config directory, the same process-cached path as MCP is used.
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
if t := strings.TrimSpace(explicitToken); t != "" {
return t, nil
}
if strings.TrimSpace(configDir) == "" {
return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty")
return "", fmt.Errorf("config directory is empty")
}
key := tokenManagerKey{
configDir: canonicalTokenConfigDir(configDir),
profile: strings.TrimSpace(authpkg.RuntimeProfile()),
}
entry := m.entry(key)
entry.mu.Lock()
defer entry.mu.Unlock()
now := time.Now()
if m != nil && m.now != nil {
now = m.now()
}
revision, present, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision {
return entry.snapshot, nil
}
// Treat the marker and credential as one optimistic snapshot. A concurrent
// login/refresh between the reads causes a retry instead of caching stale A
// under the publication marker for B.
for attempt := 0; attempt < 4; attempt++ {
beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
return tok, nil
}
snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if beforePresent != afterPresent || beforeRevision != afterRevision {
continue
}
if strings.TrimSpace(snapshot.AccessToken) == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" {
entry.snapshot = snapshot
entry.revision = afterRevision
} else {
entry.snapshot = AccessTokenSnapshot{}
entry.revision = ""
}
return snapshot, nil
return "", noCredentialsError()
}
return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials")
}
func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry {
m.mu.Lock()
defer m.mu.Unlock()
if m.entries == nil {
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
}
entry := m.entries[key]
if entry == nil {
entry = &tokenManagerEntry{}
m.entries[key] = entry
}
return entry
}
func (m *TokenManager) Invalidate() {
if m == nil {
return
}
m.mu.Lock()
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
m.mu.Unlock()
}
func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
hooks := edition.Get()
opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil
provider := hooks.TokenProvider
if provider == nil {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
// Opaque edition storage hooks have no publication-revision contract.
// Resolve them on every logical request instead of caching a token that
// may be replaced outside the default auth store.
if opaqueStorage {
snapshot.ExpiresAt = time.Time{}
}
return snapshot, nil
}
var fallbackSnapshot AccessTokenSnapshot
var fallbackCalled bool
token, err := provider(ctx, func() (string, error) {
fallbackCalled = true
var fallbackErr error
fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if fallbackErr != nil {
return "", fallbackErr
}
return fallbackSnapshot.AccessToken, nil
})
if err != nil {
return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if fallbackCalled && token == fallbackSnapshot.AccessToken {
if opaqueStorage {
fallbackSnapshot.ExpiresAt = time.Time{}
}
return fallbackSnapshot, nil
}
// Edition providers expose no lifetime metadata, so resolve them on every
// logical request instead of recreating a process-lifetime string cache.
return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil
}
func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
provider := newAccessTokenProvider(configDir)
if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok {
data, err := snapshotProvider.GetTokenSnapshot(ctx)
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
return AccessTokenSnapshot{
AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt,
Source: "oauth",
}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
token, err := provider.GetAccessToken(ctx)
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) {
token, source, err := newLegacyTokenManager(configDir).GetToken()
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if oauthErr != nil {
return AccessTokenSnapshot{}, oauthErr
}
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile())
tok, err := resolveAccessTokenFromDir(ctx, configDir)
if err != nil {
return "", err
}
return snapshot.AccessToken, nil
}
// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through
// the same TokenManager used by MCP tool calls.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken)
if err != nil {
return "", err
if tok != "" {
return tok, nil
}
return snapshot.AccessToken, nil
}
func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool {
return strings.TrimSpace(snapshot.AccessToken) != "" &&
!snapshot.ExpiresAt.IsZero() &&
now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow))
}
func canonicalTokenConfigDir(configDir string) string {
if absolute, err := filepath.Abs(configDir); err == nil {
return filepath.Clean(absolute)
}
return filepath.Clean(configDir)
return "", noCredentialsError()
}
func noCredentialsError() error {
if edition.Get().IsEmbedded {
return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound)
return fmt.Errorf("认证信息已失效,请重新认证")
}
return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound)
return fmt.Errorf("no credentials found, run: dws auth login")
}
-53
View File
@@ -5,15 +5,7 @@ package app
import (
"context"
"errors"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestResolveAuxiliaryAccessToken_explicitToken(t *testing.T) {
@@ -32,48 +24,3 @@ func TestResolveAuxiliaryAccessToken_emptyConfigDir(t *testing.T) {
t.Fatal("expected error for empty config directory")
}
}
func TestResolveAccessTokenFromDirPreservesRefreshFailure(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "expired-access",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp_refresh",
UserID: "user_refresh",
ClientID: "client_refresh",
Source: "mcp",
}); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = refreshFailureRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("refresh endpoint rejected token")
})
token, err := resolveAccessTokenFromDir(context.Background(), configDir)
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
if err == nil {
t.Fatal("resolveAccessTokenFromDir() error = nil")
}
if !strings.Contains(err.Error(), "refresh endpoint rejected token") {
t.Fatalf("error = %q, want original refresh failure", err)
}
}
type refreshFailureRoundTripFunc func(*http.Request) (*http.Response, error)
func (f refreshFailureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
-70
View File
@@ -1,70 +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 app
import (
"regexp"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
return value, nil
}
func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
-206
View File
@@ -1,206 +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 app
import (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestParseAgentHost(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err != nil {
t.Fatalf("parseAgentHost() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork_cloud\r"},
{name: "line feed", raw: "\nqwenwork_cloud"},
{name: "uppercase", raw: "Qwenwork_cloud"},
{name: "internal space", raw: "qwenwork cloud"},
{name: "internal tab", raw: "qwenwork\tcloud"},
{name: "unicode", raw: "千问办公"},
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err == nil {
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_host" {
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
}
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
mergeSawAgentHost := ""
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
mergeSawAgentHost = headers[headerDWSAgentHost]
headers["claw-type"] = "test-claw"
return headers
},
})
headers := resolveIdentityHeaders()
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
}
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
t.Fatalf("agentCode header = %q, want agent-code-test", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
if got := headers["claw-type"]; got != "test-claw" {
t.Fatalf("claw-type = %q, want test-claw", got)
}
}
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSChannel, "channel-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
return headers
},
})
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
t.Setenv(envDWSAgentHost, raw)
headers := resolveIdentityHeaders()
if _, ok := headers[headerDWSAgentHost]; ok {
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
-75
View File
@@ -1,75 +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 app
import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
Example: "qwenwork",
})
}
// parseAgentProduct converts the reusable package error into the CLI's stable
// structured validation error without exposing the untrusted raw value.
func parseAgentProduct(raw string) (string, error) {
value, err := agentproduct.Parse(raw)
if err != nil {
return "", invalidAgentProductError()
}
return value, nil
}
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
func resolveEditionClawType(headers map[string]string) string {
if value := headers["claw-type"]; value != "" {
return value
}
return edition.DefaultOSSClawType
}
// applyAgentProductHeader injects only a valid, non-empty caller-declared
// product. Invalid values are omitted on library paths that bypass root
// validation; normal CLI execution rejects them before network access.
func applyAgentProductHeader(headers map[string]string) map[string]string {
value, err := agentproduct.ResolveFromEnv("")
if err != nil || value == "" {
if headers != nil {
delete(headers, agentproduct.HeaderName)
}
return headers
}
if headers == nil {
headers = make(map[string]string)
}
headers[agentproduct.HeaderName] = value
return headers
}
-333
View File
@@ -1,333 +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 app
import (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
const invalidValue = "DO_NOT ECHO"
got, err := parseAgentProduct(invalidValue)
if got != "" {
t.Fatalf("parseAgentProduct() = %q, want empty", got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_product" {
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "wukong"
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "credential-must-not-win"
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
})
t.Run("valid Product is final without changing claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
if got := headers["x-enterprise-header"]; got != "preserved" {
t.Fatalf("enterprise header = %q, want preserved", got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
})
t.Run("invalid library input omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
}
})
}
func TestApplyAgentProductHeader(t *testing.T) {
t.Run("valid value allocates headers", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductHeader(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
})
for _, tc := range []struct {
name string
value string
}{
{name: "empty value", value: ""},
{name: "invalid value", value: "qwen work"},
} {
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, tc.value)
headers := applyAgentProductHeader(map[string]string{
agentproduct.HeaderName: "must-not-leak",
"x-preserved": "yes",
})
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
}
if got := headers["x-preserved"]; got != "yes" {
t.Fatalf("x-preserved = %q, want yes", got)
}
})
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers["claw-type"] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "wukong"
return headers
},
})
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("HTTP claw-type = %q, want wukong", got)
}
if got := edition.ClawType(); got != "qwenwork" {
t.Fatalf("message clawType = %q, want qwenwork", got)
}
}
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
}
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(map[string]string) map[string]string {
return nil
},
})
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
func TestEffectiveClawTypeIgnoresAgentProduct(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers["claw-type"] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
t.Fatalf("hostControl.clawType = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "invalid product")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
}
}
-331
View File
@@ -1,331 +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 app
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
// apiFlags holds the flags specific to the `dws api` command.
type apiFlags struct {
params string
data string
pageAll bool
pageLimit int
pageDelay int
baseURL string
}
type appTokenGetter interface {
GetToken(context.Context) (string, error)
}
var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter {
return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret}
}
var (
apiClientID = authpkg.ClientID
apiClientSecret = authpkg.ClientSecret
)
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
func newAPICommand(flags *GlobalFlags) *cobra.Command {
af := &apiFlags{}
cmd := &cobra.Command{
Use: "api <METHOD> <PATH> [flags]",
Short: "调用钉钉 OpenAPI (Raw HTTP)",
Long: `直接调用钉钉 OpenAPI,支持 api.dingtalk.com 和 oapi.dingtalk.com 两个域名。
api.dingtalk.com:
Token 通过 HTTP Header (x-acs-dingtalk-access-token) 传递。
路径格式: /v1.0/xxx 或 /v2.0/xxx
oapi.dingtalk.com:
Token 通过 URL 查询参数 (access_token) 传递。
路径格式: /topapi/v2/xxx 或完整 URL https://oapi.dingtalk.com/topapi/...
仅限使用自有应用凭证(--client-id/--client-secret)登录后使用。
通过 MCP 默认凭证登录获取的加密 token 不支持 raw API 调用。
示例:
# === api.dingtalk.com ===
# 获取当前用户信息
dws api GET /v1.0/contact/users/me
# 搜索用户 (POST + JSON body)
dws api POST /v1.0/contact/users/search \
--data '{"queryWord":"张三","offset":0,"size":10}'
# 创建日历事件
dws api POST /v1.0/calendar/users/me/calendars/primary/events \
--data '{"summary":"Team Meeting","start":{"dateTime":"2026-01-01T10:00:00+08:00"}}'
# === oapi.dingtalk.com ===
# 获取用户详情 (使用 --base-url)
dws api POST /topapi/v2/user/get \
--base-url https://oapi.dingtalk.com \
--data '{"userid":"manager123"}'
# 也可以直接使用完整 URL
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
--data '{"userid":"manager123"}'
# === 通用功能 ===
# 分页获取所有结果
dws api GET /v1.0/attendance/groups --page-all --page-limit 5
# Dry-run 预览请求
dws api GET /v1.0/contact/users/me --dry-run
# 使用 jq 过滤输出
dws api GET /v1.0/contact/users/me --jq '.nick'`,
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPI(cmd, args, flags, af)
},
}
cmd.Flags().StringVar(&af.params, "params", "", "查询参数 JSON (支持 - 从 stdin 读取)")
cmd.Flags().StringVar(&af.data, "data", "", "请求体 JSON (支持 - 从 stdin 读取)")
cmd.Flags().BoolVar(&af.pageAll, "page-all", false, "自动遍历所有分页")
cmd.Flags().IntVar(&af.pageLimit, "page-limit", apiclient.DefaultPageLimit, "最大翻页数 (0=不限, 默认10, 硬上限500)")
cmd.Flags().IntVar(&af.pageDelay, "page-delay", apiclient.DefaultPageDelay, "分页间隔毫秒")
cmd.Flags().StringVar(&af.baseURL, "base-url", "", "覆盖 API 基础 URL (默认 https://api.dingtalk.com)")
return cmd
}
// runAPI is the main execution logic for `dws api`.
func runAPI(cmd *cobra.Command, args []string, gf *GlobalFlags, af *apiFlags) error {
ctx := cmd.Context()
method := args[0]
path := args[1]
// 0. Reject path with inline query string — must use --params instead.
if idx := strings.IndexByte(path, '?'); idx >= 0 {
cleanPath := path[:idx]
// Parse query string to generate the exact --params JSON for the user.
paramsJSON := parseQueryStringToJSON(path[idx+1:])
return apperrors.NewValidation(
"API 路径中不允许直接拼接查询参数(?key=value),该写法会导致参数在解析时被静默丢弃。\n\n"+
"命令格式可参考:\n\n"+
" dws api "+method+" "+cleanPath+" --params '"+paramsJSON+"'",
apperrors.WithHint("查询参数必须通过 --params 传递,形如 --params '{\"key\":\"value\"}'"),
)
}
// 1. Validate HTTP method.
method, err := apiclient.ValidateMethod(method)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 2. Validate API path.
if err := apiclient.ValidatePath(path); err != nil {
return apperrors.NewValidation(err.Error())
}
// 3. Validate input safety for params and data.
if err := apiclient.ValidateUserInput(af.params, "--params"); err != nil {
return apperrors.NewValidation(err.Error())
}
if err := apiclient.ValidateUserInput(af.data, "--data"); err != nil {
return apperrors.NewValidation(err.Error())
}
// 4. Validate mutual exclusion.
if err := apiclient.ValidateStdinExclusion(af.params, af.data); err != nil {
return apperrors.NewValidation(err.Error())
}
if err := apiclient.ValidateFlagExclusion(gf.Output, af.pageAll); err != nil {
return apperrors.NewValidation(err.Error())
}
// 5. Parse --params.
params, err := apiclient.ParseJSONMap(af.params, "--params", os.Stdin)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 6. Parse --data.
body, err := apiclient.ParseOptionalBody(method, af.data, os.Stdin)
if err != nil {
return apperrors.NewValidation(err.Error())
}
// 7. Normalise and validate target URL.
fullURL := apiclient.NormalisePath(path, af.baseURL)
// 7b. Security: validate target host is a trusted DingTalk domain.
if err := apiclient.ValidateTargetHost(fullURL); err != nil {
return apperrors.NewValidation(err.Error())
}
// 8. Resolve app-level token (with timeout).
tokenCtx, tokenCancel := context.WithTimeout(ctx, 15*time.Second)
defer tokenCancel()
token, err := resolveRawAPIToken(tokenCtx, gf.Token)
if err != nil {
return err
}
// 9. Build request.
req := apiclient.RawAPIRequest{
Method: method,
Path: path,
Params: params,
Data: body,
}
baseURL := af.baseURL
// 10. Dry-run mode.
if gf.DryRun {
return apiclient.PrintDryRun(cmd.OutOrStdout(), req, baseURL, token)
}
// 11. Create client with timeout.
client := apiclient.NewClient(token, baseURL)
if gf.Timeout > 0 {
client.HTTPClient.Timeout = time.Duration(gf.Timeout) * time.Second
}
// 12. Execute request (with or without pagination).
format := output.Format(gf.Format)
respOpts := apiclient.ResponseOptions{
OutputPath: gf.Output,
Format: format,
JqExpr: gf.JQ,
Fields: gf.Fields,
Out: cmd.OutOrStdout(),
ErrOut: cmd.ErrOrStderr(),
}
if af.pageAll {
return runPaginated(ctx, client, req, af, respOpts)
}
resp, err := client.Do(ctx, req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("API 请求失败: %v", err))
}
return apiclient.HandleResponse(resp, respOpts)
}
// runPaginated executes a paginated API request and outputs all results.
func runPaginated(ctx context.Context, client *apiclient.APIClient, req apiclient.RawAPIRequest, af *apiFlags, opts apiclient.ResponseOptions) error {
pages, err := client.PaginateAll(ctx, req, apiclient.PaginationOptions{
PageLimit: af.pageLimit,
PageDelay: af.pageDelay,
LogWriter: opts.ErrOut,
})
if err != nil && len(pages) == 0 {
return apperrors.NewAPI(fmt.Sprintf("分页请求失败: %v", err))
}
// Output all pages as a JSON array.
return output.WriteFiltered(opts.Out, opts.Format, pages, opts.Fields, opts.JqExpr)
}
// parseQueryStringToJSON parses a raw URL query string into a JSON object string.
// Uses simple & and = splitting (no URL decoding) to preserve values as-is.
func parseQueryStringToJSON(rawQuery string) string {
rawQuery = strings.TrimSpace(rawQuery)
if rawQuery == "" {
return "{}"
}
paramsMap := make(map[string]any)
for _, pair := range strings.Split(rawQuery, "&") {
kv := strings.SplitN(pair, "=", 2)
key := strings.TrimSpace(kv[0])
if key == "" {
continue
}
var val string
if len(kv) == 2 {
val = strings.TrimSpace(kv[1])
}
if val == "" {
continue // skip empty values like nextToken=
}
paramsMap[key] = val
}
if len(paramsMap) == 0 {
return "{}"
}
data, _ := json.Marshal(paramsMap)
return string(data)
}
// resolveRawAPIToken resolves an app-level access token for raw API calls.
// It uses AppTokenProvider to fetch from the unified POST /v1.0/oauth2/accessToken
// endpoint. The same token works for both api.dingtalk.com and oapi.dingtalk.com.
// Tokens are cached in keychain and auto-refreshed when expired.
func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, error) {
// Explicit --token flag takes priority (user knows what they're doing).
if t := strings.TrimSpace(explicitToken); t != "" {
return t, nil
}
// Resolve app credentials (clientID/clientSecret).
appKey := apiClientID()
appSecret := apiClientSecret()
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
return "", apperrors.NewAuth(
"缺少应用凭证。dws api 需要使用自有应用的 AppKey/AppSecret 获取 accessToken。\n\n" +
"解决方法:\n" +
" 1. 使用自有应用凭证登录:\n" +
" dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>\n\n" +
" 2. 或通过环境变量设置:\n" +
" export DWS_CLIENT_ID=<APP_KEY>\n" +
" export DWS_CLIENT_SECRET=<APP_SECRET>\n" +
" dws auth login\n\n" +
"说明: 通过 MCP 默认凭证登录的加密 token 无法用于 raw API 调用。",
)
}
// Use AppTokenProvider for automatic caching and refresh.
configDir := defaultConfigDir()
provider := newAppTokenProvider(configDir, appKey, appSecret)
token, err := provider.GetToken(ctx)
if err != nil {
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
}
if strings.TrimSpace(token) == "" {
return "", apperrors.NewAuth("应用级访问令牌为空,请检查应用凭证是否正确")
}
return strings.TrimSpace(token), nil
}
-110
View File
@@ -1,110 +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 app
import (
"bytes"
"strings"
"testing"
)
func TestParseQueryStringToJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name, raw, want string
}{
{
name: "simple key-value",
raw: "timeMin=2026-04-01&maxResults=10",
want: `{"maxResults":"10","timeMin":"2026-04-01"}`,
},
{
name: "with special chars",
raw: "timeMin=2026-04-01T14:00:00+08:00&showDeleted=false",
want: `{"showDeleted":"false","timeMin":"2026-04-01T14:00:00+08:00"}`,
},
{
name: "empty value skipped",
raw: "nextToken=&syncToken=abc",
want: `{"syncToken":"abc"}`,
},
{
name: "all empty",
raw: "nextToken=&syncToken=",
want: "{}",
},
{
name: "empty string",
raw: "",
want: "{}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseQueryStringToJSON(tt.raw)
if got != tt.want {
t.Errorf("parseQueryStringToJSON(%q) = %s, want %s", tt.raw, got, tt.want)
}
})
}
}
func TestRunAPI_QueryStringBlocked(t *testing.T) {
t.Parallel()
gf := &GlobalFlags{}
cmd := newAPICommand(gf)
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"GET", "/v1.0/calendar/users/me/events?timeMin=2026-04-01&maxResults=10"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when path contains query string, got nil")
}
errMsg := stderr.String()
if !strings.Contains(errMsg, "--params") {
t.Errorf("expected --params hint in error, got: %s", errMsg)
}
if !strings.Contains(errMsg, "maxResults") {
t.Errorf("expected parsed query params in error, got: %s", errMsg)
}
if !strings.Contains(errMsg, "/v1.0/calendar/users/me/events") {
t.Errorf("expected clean path in suggestion, got: %s", errMsg)
}
}
func TestRunAPI_NoErrorWithoutQueryString(t *testing.T) {
t.Parallel()
gf := &GlobalFlags{}
cmd := newAPICommand(gf)
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetOut(&bytes.Buffer{})
cmd.SetArgs([]string{"GET", "/v1.0/contact/users/me"})
err := cmd.Execute()
errMsg := stderr.String()
if strings.Contains(errMsg, "查询参数") {
t.Errorf("should not reject path without query string, got: %s", errMsg)
}
_ = err
}

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