Compare commits

..
196 changed files with 2064 additions and 38512 deletions
@@ -1,5 +0,0 @@
---
category: Added
---
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
@@ -1,6 +0,0 @@
---
category: Added
---
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
@@ -1,24 +0,0 @@
---
category: Changed
---
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
@@ -1,22 +0,0 @@
---
category: Added
---
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
@@ -1,5 +0,0 @@
---
category: Added
---
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
@@ -1,13 +0,0 @@
---
category: Added
---
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
+3 -8
View File
@@ -544,8 +544,7 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -693,8 +692,7 @@ jobs:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
@@ -1599,10 +1597,7 @@ jobs:
name: Policy
needs: lint
runs-on: ubuntu-latest
# Full policy regenerates and validates the runtime Schema several times.
# Keep job-level headroom for large reviewed command-surface additions;
# individual policy gates retain their own fail-closed checks.
timeout-minutes: 15
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
+9 -15
View File
@@ -14,12 +14,9 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const webhooks = [
process.env.DINGTALK_WEBHOOK,
process.env.DINGTALK_WEBHOOK_SECONDARY
].filter(Boolean);
if (webhooks.length === 0) {
console.log('⚠️ No DingTalk webhook configured, skipping notification');
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
return;
}
@@ -42,15 +39,12 @@ jobs:
}
};
await Promise.all(webhooks.map(webhook =>
fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
));
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
console.log('✅ DingTalk notification sent');
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
-80
View File
@@ -6,86 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.2] - 2026-08-24
### Added
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
### Changed
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
### Fixed
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
## [1.0.60-beta.1] - 2026-08-21
### Changed
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.60-beta.2"
version "1.0.60-beta.1"
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.60-beta.2/dws-darwin-arm64.tar.gz"
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
+2 -6
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof 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 test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -20,7 +20,6 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\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"
@@ -63,9 +62,6 @@ test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
shortcut-public-e2e-proof: build
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
lint:
@./scripts/dev/lint.sh
@@ -88,7 +84,7 @@ fmt:
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy: test-auth-legacy-compat shortcut-public-e2e-proof
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
+5 -63
View File
@@ -13,71 +13,13 @@ if (!fs.existsSync(binaryPath)) {
process.exit(1);
}
// Interactive commands must remain in the terminal's foreground session so
// prompts can use /dev/tty. Non-interactive launches use a separate process
// group, allowing a signal sent only to this wrapper to reach the full vendor
// process tree exactly once.
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
stdio: "inherit",
detached: isolateVendorProcessGroup,
});
let spawnFailed = false;
let forwardedSignal = null;
const forwardedSignals = ["SIGINT", "SIGTERM"];
function forwardSignal(signal) {
forwardedSignal = signal;
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === "win32") {
child.kill(signal);
return;
}
if (!isolateVendorProcessGroup) {
// Ctrl-C is generated for the whole foreground process group, including
// the vendor. SIGTERM is not terminal-generated and still needs an
// explicit handoff when a process manager targets only this wrapper.
if (signal === "SIGTERM") {
child.kill(signal);
}
return;
}
try {
// detached makes the vendor PID the leader of its POSIX process group.
// Signal the whole group so any subprocesses inherit the same shutdown.
process.kill(-child.pid, signal);
} catch (error) {
// The group may have completed between the state check and kill.
if (error.code !== "ESRCH") {
throw error;
}
}
}
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
const signalHandlers = new Map(
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
);
for (const signal of forwardedSignals) {
process.on(signal, signalHandlers.get(signal));
}
child.on("error", (error) => {
spawnFailed = true;
console.error(error.message);
});
child.on("close", (code, signal) => {
for (const forwarded of forwardedSignals) {
process.removeListener(forwarded, signalHandlers.get(forwarded));
}
const exitSignal = forwardedSignal || signal;
if (exitSignal && process.platform !== "win32") {
process.kill(process.pid, exitSignal);
return;
}
process.exitCode = spawnFailed || code === null ? 1 : code;
});
process.exit(result.status === null ? 1 : result.status);
-454
View File
@@ -1,454 +0,0 @@
# AI 表格数据源指令使用指南
## 概述
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
## 指令速览
| 指令 | 用途 | 读写 | 风险 |
|------|------|------|------|
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
## 前置条件
1. **登录认证**:执行 `dws auth login` 确保已登录
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
---
## 1. 列出数据源可用来源
```
dws aitable +datasource-list-sources [flags]
```
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
### 示例
```bash
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
```
### 返回值
返回 `sources` 数组,每个条目包含:
| 字段 | 说明 |
|------|------|
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
| `sourceUrl` | 数据源访问链接,可选 |
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
---
## 2. 获取数据源可同步字段
```
dws aitable +datasource-get-fields [flags]
```
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
### 示例
```bash
# 获取某审批模板的可同步字段
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
```
### 返回值
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
---
## 3. 创建数据源表
```
dws aitable +datasource-create [flags]
```
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
### source-config 格式(审批类)
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
按 `dataType` 选择对应的时间参数组合:
| dataType | 需要的时间字段 | 说明 |
|----------|----------------|------|
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
| `start_time` | `startDate` | 同步从某日期至今的数据 |
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
### auto-sync-setting 格式
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
### 示例
```bash
# 基本创建——同步近 30 天审批数据
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 指定日期范围创建并开启自动同步
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--auto
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--field-ids fldAAA,fldBBB,fldCCC
```
### 返回值
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
---
## 4. 更新数据源配置
```
dws aitable +datasource-update [flags]
```
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
### 示例
```bash
# 更换审批模板并调整时间范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 更新同步字段范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--field-ids fldAAA,fldDDD
```
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
---
## 5. 触发手动同步
```
dws aitable +datasource-sync [flags]
```
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
### 示例
```bash
# 同步单张表
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1
# 批量同步多张表(逗号分隔,最多 5 个)
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1,TBL2,TBL3
```
### 返回值
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
---
## 6. 查询同步状态
```
dws aitable +datasource-sync-status [flags]
```
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
### 示例
```bash
# 按任务 ID 查询(批量,最多 5 个)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK1,TASK2
```
---
## 7. 获取数据源配置
```
dws aitable +datasource-get-config [flags]
```
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
### 示例
```bash
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
---
## 典型工作流
### 场景一:从零接入审批数据
```bash
# 0. 获取 Base ID
dws aitable +base-search --query "我的项目表"
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
# 2. 查看可同步字段(可选,用于指定 field-ids)
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 3. 创建数据源表(创建后自动触发首次同步)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# → 返回 tableId=TBL456, taskId=TASK001
# 4. 查询首次同步是否完成
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
# 5. 确认配置
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
### 场景二:更换审批模板后重新同步
```bash
# 1. 更新源配置(更新后自动触发一次同步)
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 2. 查询同步状态(更新后会返回新的 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK002
```
### 场景三:手动触发日常同步
```bash
# 仅触发同步,不修改配置
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL456
# 查询结果(sync 会返回 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
```
### 场景四:开启自动同步后确认
```bash
# 1. 更新配置,开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 2. 确认配置已更新
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
# → 返回中应显示 auto=true
```
---
## 通用选项
以下全局选项可在所有指令中使用:
| 选项 | 说明 |
|------|------|
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
| `--fields` | 筛选输出字段(逗号分隔) |
| `--dry-run` | 预览操作内容,不实际执行 |
| `--profile` | 指定组织或账号 |
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
| `--debug` | 显示调试日志 |
| `-v, --verbose` | 显示详细日志 |
### 输出过滤示例
```bash
# 只取 tableId
dws aitable +datasource-create ... --jq '.tableId'
# 只取同步状态
dws aitable +datasource-sync-status ... --jq '.status'
# table 格式查看
dws aitable +datasource-get-config ... -f table
```
---
## 注意事项
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
@@ -0,0 +1,310 @@
# Attendance Shortcut 下游业务能力需求规格
> 日期:2026-08-18
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
> 对比基线:Lark CLI 1.0.87
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
## 1. 执行摘要
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|---|---|---|---|---|---|---|
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
## 2. 用户任务与能力缺口总览
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|---|---|---|---|---|---|
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
## 3. 下游需求明细
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
#### A. 用户任务与现状
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
#### B. 需要下游提供的合同
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
#### C. 验收标准
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
#### D. 临时处置
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
#### A. 用户任务与现状
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
- 安全证据句柄:`ATT-REPORT-NULL-01`。
#### B. 需要下游提供的合同
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
#### C. 验收标准
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
5. 分页/partial 分支和远端零残留通过。
#### D. 临时处置
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
#### A. 用户任务与现状
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
#### B. 需要下游提供的合同
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
#### C. 验收标准与临时处置
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
#### A. 用户任务与现状
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
#### B. 需要的测试基础设施与合同
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
#### C. 验收标准与临时处置
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
5. 测试结束远端和本地均零残留。
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
#### A. 用户任务与现状
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
#### B. 需要下游提供的合同与 fixture
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
#### C. 验收标准与临时处置
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
#### A. 用户任务与现状
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
#### B. 需要下游提供的合同
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
- 改动需 additive/versioned;现有详情业务字段保持兼容。
#### C. 验收标准与临时处置
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
#### A. 用户任务与现状
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
#### B. 需要下游提供的合同与 fixture
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
#### C. 验收标准与临时处置
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
#### A. 用户任务与现状
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
#### B. 需要下游提供的合同
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
#### C. 验收标准与临时处置
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
## 4. Lark 对齐与平台差异
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|---|---|---|---|---|
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
## 5. 超越 Lark 的产品机会
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|---|---|---|---|---|
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
## 6. 无需下游变更的上游修复
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|---|---|---|---|
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
### 6.1 clean-HEAD live 发布门状态
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|---|---|---|
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
## 7. 安全与脱敏声明
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
@@ -0,0 +1,198 @@
# Mail Shortcut 下游业务能力需求规格
> 日期:2026-08-18
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
> 对比基线:lark-cli 1.0.87
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
## 1. 执行摘要
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|---|---|---|---|---|---|---|
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
## 2. 用户任务与能力缺口总览
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|---|---|---|---|---|---|
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
## 3. 下游需求明细
### `DS-Mail-001` — 可验证的发送生命周期
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
- 所需接口合同:
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
- 支持幂等键,或明确 unknown commit 不可自动重试。
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
- 所需接口合同:
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
- 所需接口合同:
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
### `DS-Mail-004` — 已读回执与签名
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
- 所需接口合同:
- 消息详情公开稳定回执请求状态和请求者身份类型。
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
### `DS-Mail-005` — 新邮件监听的订阅生命周期
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
### `DS-Mail-006` — 模板附件与内联图片事务
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
### `DS-Mail-007` — 联系人写操作的稳定身份
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
### `DS-Mail-008` — 统一成功、空结果与分页协议
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
- 所需接口合同:
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
- 保留兼容期,但提供 capability/version 让上游安全切换。
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
### `DS-Mail-009` — 安全租户与真实 E2E fixture
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
### `DS-Mail-010` — 草稿/模板可证明的清理终态
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
## 4. Lark 对齐与平台差异
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|---|---|---|---|
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
| `+signature` | no | 缺签名读取资源 | platform unavailable |
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
## 5. 超越 Lark 的产品机会
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|---|---|---|---|
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
## 6. 无需下游变更的上游修复
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|---|---|---|---|
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
### 6.1 clean executable HEAD 双层证据
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|---|---|---|
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
## 7. 安全与脱敏声明
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
+39 -187
View File
@@ -1,17 +1,7 @@
{
"generated_at": "2026-08-24T12:22:05.108140",
"count": 426,
"generated_at": "2026-08-20T11:51:44.156046",
"count": 416,
"results": [
{
"suite": "semantic",
"service": "aisearch",
"command": "+search-person",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -282,76 +272,6 @@
"semantic_delta": "更新仪表盘配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-config",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-get-fields",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-list-sources",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-sync-status",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+datasource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -2343,134 +2263,102 @@
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+by-mobile",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+dept-members",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+list-dept-members",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+list-followings",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+list-role-members",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+list-roles",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "contact",
"command": "+list-sub-depts",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+lookup",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+me",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+org",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+resolve-dept",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+search-mobile",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+search-user",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "contact",
"command": "+team",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "write",
@@ -3705,16 +3593,6 @@
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"availability": "available"
},
{
"suite": "semantic",
"service": "pat",
"command": "+browser-policy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
@@ -3756,24 +3634,18 @@
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "sheet",
"command": "+list-sheets",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "sheet",
"command": "+read",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
@@ -3985,26 +3857,6 @@
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+query",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+update",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
-1
View File
@@ -10,7 +10,6 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
-11
View File
@@ -1295,17 +1295,6 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
hasTarget := false
for _, id := range subscribeIDs {
if strings.TrimSpace(id) != "" {
hasTarget = true
break
}
}
if !hasTarget {
return nil
}
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
+1 -13
View File
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
})
}
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
@@ -746,18 +746,6 @@ func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallb
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
t.Fatal("targeted stop called without a subscribe_id")
return transport.ConsumerStopResp{}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried without a subscribe_id")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
t.Fatalf("empty target stop = %v", err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
@@ -1,175 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
root := NewRootCommand()
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
}
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
registered := 0
for _, item := range shortcut.All() {
if item.Service == "markdown" {
registered++
}
}
if registered != 0 {
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
}
type route struct {
canonical string
confirmation string
flags []string
}
routes := map[string]route{
"create": {
canonical: "markdown.create", confirmation: "not_required",
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
},
"fetch": {
canonical: "markdown.fetch", confirmation: "not_required",
flags: []string{"node", "output", "space-id", "workspace"},
},
"overwrite": {
canonical: "markdown.overwrite", confirmation: "user_required",
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
},
"patch": {
canonical: "markdown.patch", confirmation: "user_required",
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
},
"diff": {
canonical: "markdown.diff", confirmation: "not_required",
flags: []string{"context", "file", "node", "version", "version2"},
},
}
group := mustFindCommand(t, root, "markdown")
children := map[string]bool{}
for _, child := range group.Commands() {
children[child.Name()] = true
}
if len(children) != len(routes) {
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
}
for name, want := range routes {
leaf := mustFindCommand(t, root, "markdown", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !children[name] {
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
}
for _, flag := range want.flags {
if leaf.Flags().Lookup(flag) == nil {
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
}
}
if shortcut.InPublicCatalog("markdown", "+"+name) {
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
}
meta, ok := cli.ResolveMeta("markdown " + name)
if !ok {
t.Errorf("markdown %s missing from assembled Schema", name)
continue
}
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
}
if meta.Safety.Confirmation != want.confirmation {
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
}
tool := tools[want.canonical]
if tool == nil {
t.Errorf("markdown %s missing from full delivery Schema", name)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("markdown %s availability=%q, want available", name, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
}
}
}
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
driveShortcuts := map[string]string{
"+copy": "drive.shortcut_copy",
"+delete": "drive.shortcut_delete",
"+find-file": "drive.shortcut_find_file",
"+list": "drive.shortcut_list",
"+move": "drive.shortcut_move",
"+publish-get": "drive.shortcut_publish_get",
"+recycle-restore": "drive.shortcut_recycle_restore",
"+rename": "drive.shortcut_rename",
"+version-download": "drive.shortcut_version_download",
"+version-get": "drive.shortcut_version_get",
"+version-history": "drive.shortcut_version_history",
"+version-revert": "drive.shortcut_version_revert",
}
for name, canonical := range driveShortcuts {
leaf := mustFindCommand(t, root, "drive", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !shortcut.InPublicCatalog("drive", name) {
t.Errorf("drive %s is not in the public Shortcut catalog", name)
}
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
}
ordinaryRoutes := map[string]string{
"drive permission list": "drive.list_permission",
"drive pull": "drive.folder_pull",
"drive push": "drive.folder_push",
"drive status": "drive.folder_status",
"drive sync": "drive.folder_sync",
"wiki node list": "wiki.list_nodes",
}
for cliPath, canonical := range ordinaryRoutes {
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
}
}
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
t.Helper()
meta, ok := cli.ResolveMeta(cliPath)
if !ok {
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
return
}
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
}
tool := tools[canonical]
if tool == nil {
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
return
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
}
}
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
@@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
@@ -231,113 +231,12 @@ var paramAliasCompleteCommands = map[string][]string{
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for
// reviewed parameter-concept product drafts. Keeping candidate-only commands
// in a separate map lets a test change land before a draft replaces the formal
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
"contact +org": {"contact", "+org", "--name", "Fixture User"},
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
"contact +team": {"contact", "+team", "--name", "Fixture User"},
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
@@ -397,20 +296,6 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"markdown create": {
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
},
"markdown diff": {
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
},
"markdown overwrite": {
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
},
"markdown patch": {
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
},
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
@@ -1084,122 +969,6 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
}
}
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
// exercises every distinct reviewed mutating complete-command template in the
// reviewed product expansions. The fixture gate already proves every
// alias resolves through PreParse; this gate removes the confirmation flag
// from one active alias invocation per distinct template and requires the
// runtime boundary to stop it before the first transport call. An explicit
// --dry-run is a reviewed preview path and must not carry a bypass flag.
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
requiredTemplates := make(map[string]bool)
coveredTemplates := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
default:
continue
}
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
continue
}
_, yesCount := removeExactArg(complete, "--yes")
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
confirmationCount := yesCount + userSayYesCount
confirmationArg := "--yes"
if userSayYesCount == 1 {
confirmationArg = "--user-say-yes"
}
_, dryRunCount := removeExactArg(complete, "--dry-run")
if dryRunCount > 1 {
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
continue
}
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
switch meta.Safety.Confirmation {
case "user_required":
if dryRunCount == 1 {
if confirmationCount != 0 {
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
}
continue
}
if confirmationCount != 1 {
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
case "not_required":
if confirmationCount != 0 {
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
}
}
if confirmationCount == 0 {
continue
}
if confirmationCount != 1 {
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
continue
}
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
requiredTemplates[templateKey] = true
if coveredTemplates[templateKey] {
continue
}
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
continue
}
coveredTemplates[templateKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
})
}
if len(requiredTemplates) == 0 {
t.Fatal("reviewed complete-command templates contain no confirmation cases")
}
if len(coveredTemplates) != len(requiredTemplates) {
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
}
}
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
if removals != 1 {
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed alias command skipped PreParse")
}
var appErr *apperrors.Error
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
return
}
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
-2
View File
@@ -740,7 +740,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
@@ -766,7 +765,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
@@ -1,70 +0,0 @@
package app
import (
"testing"
)
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"sheet.create_float_image",
"sheet.update_float_image",
)
create := payload.Tools["sheet.create_float_image"]
if create == nil {
t.Fatal("missing sheet.create_float_image")
}
if create["interface_mode"] != "mcp" {
t.Fatalf("create interface mode = %#v", create["interface_mode"])
}
createRef, _ := create["interface_ref"].(map[string]any)
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
t.Fatalf("create interface ref = %#v", createRef)
}
createDryRun, _ := create["dry_run"].(map[string]any)
if createDryRun["preview_kind"] != "request" {
t.Fatalf("create dry-run = %#v", createDryRun)
}
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
}
createParameters, _ := create["parameters"].(map[string]any)
file, _ := createParameters["file"].(map[string]any)
src, _ := createParameters["src"].(map[string]any)
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
t.Fatalf("create --file metadata = %#v", file)
}
if schemaContractString(file["property"]) != "" {
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
}
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
t.Fatalf("create --src compatibility metadata = %#v", src)
}
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
update := payload.Tools["sheet.update_float_image"]
if update == nil {
t.Fatal("missing sheet.update_float_image")
}
updateDryRun, _ := update["dry_run"].(map[string]any)
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
}
updateParameters, _ := update["parameters"].(map[string]any)
updateFile, _ := updateParameters["file"].(map[string]any)
if schemaContractString(updateFile["property"]) != "" {
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
}
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
root := NewRootCommand()
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
command := exactCommandForTest(root, cliPath)
if command == nil || command.Flags().Lookup("file") == nil {
t.Fatalf("%s has no executable --file flag", cliPath)
}
}
}
+3 -76
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 425
publicShortcutCount = 415
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 482
schemaPublishedShortcutCount = 453
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 425
publiclyDeliveredShortcutCount = 415
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -271,60 +271,6 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
}
}
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
continue
}
if !strings.HasPrefix(declared.Command, "+datasource-") {
continue
}
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
continue
}
checked++
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("delivery schema --all is missing %s", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 {
t.Fatalf("%s has no delivered examples", canonical)
}
for _, example := range examples {
if !strings.Contains(example, "--source-config") {
continue
}
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
sourceConfig := schemaExampleFlagValue(argv, "source-config")
if sourceConfig == "" {
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
continue
}
var cfg map[string]any
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
continue
}
for _, member := range requiredSourceConfigMembers {
if _, ok := cfg[member]; !ok {
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
}
}
}
}
if checked != 3 {
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
@@ -336,25 +282,6 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
return false
}
func schemaExampleFlagValue(argv []string, name string) string {
prefix := "--" + name + "="
for _, argument := range argv {
if argument == "--"+name {
continue
}
if strings.HasPrefix(argument, prefix) {
return strings.TrimPrefix(argument, prefix)
}
}
// Value may be in the next argv entry: `--flag value` form.
for i := 0; i < len(argv)-1; i++ {
if argv[i] == "--"+name {
return argv[i+1]
}
}
return ""
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
+8 -47
View File
@@ -20,50 +20,18 @@ import (
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
operation string
retryable *bool
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
combined := text + " " + detail
if code == "999" &&
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
classified := serverFailureClass{
message: message,
reason: "upstream_internal_error",
origin: "dingtalk_api",
stage: "upstream_execution",
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
actions: []string{
"检查目标资源的当前状态,确认本次操作是否已经生效",
"状态未确认前不要直接重试写操作",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
},
}
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
retryable := false
classified.operation = "todo/create_personal_todo"
classified.retryable = &retryable
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
classified.actions = []string{
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
"确认没有创建成功后再重新提交",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
}
}
return classified, true
}
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
@@ -106,7 +74,6 @@ func newServerFailureAPIError(
fallbackReason string,
fallbackHint string,
serverKey string,
tool string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
@@ -117,7 +84,7 @@ func newServerFailureAPIError(
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
if classified, ok := classifyServerFailure(message, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
@@ -126,12 +93,6 @@ func newServerFailureAPIError(
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
if classified.operation != "" {
opts = append(opts, apperrors.WithOperation(classified.operation))
}
if classified.retryable != nil {
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
}
}
return apperrors.NewAPI(message, opts...)
}
@@ -34,7 +34,6 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
@@ -67,7 +66,6 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
"business_error",
"check parameters",
"chat",
"send_message",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
@@ -82,74 +80,12 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
}
}
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
serverSaysRetryable := true
err := newServerFailureAPIError(
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
"business_error",
"The API returned a business-level error. Check required parameters and values.",
"todo",
"create_personal_todo",
apperrors.ServerDiagnostics{
TraceID: "trace-todo-create",
ServerErrorCode: "999",
ServerRetryable: &serverSaysRetryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.Operation != "todo/create_personal_todo" {
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
}
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
t.Fatalf("hint = %q", typed.Hint)
}
for _, action := range typed.Actions {
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
t.Fatalf("misleading action = %q", action)
}
}
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "upstream_internal_error",
"origin": "dingtalk_api",
"stage": "upstream_execution",
"retryable": false,
"trace_id": "trace-todo-create",
"server_error_code": "999",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
@@ -170,7 +106,6 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
@@ -188,7 +123,6 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
@@ -290,58 +224,3 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "999",
"trace_id": "trace-todo-replay",
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "todo",
Tool: "create_personal_todo",
CanonicalPath: "todo.create_personal_todo",
Params: map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": "fixture",
"executorIds": []string{"user-1"},
},
},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
}
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
}
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
}
@@ -1,42 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/spf13/cobra"
)
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
for canonical, command := range map[string]string{
"whiteboard.shortcut_query": "+query",
"whiteboard.shortcut_update": "+update",
} {
leaf, _, err := root.Find([]string{"whiteboard", command})
if err != nil || leaf == nil || leaf.Name() != command {
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
} else if leaf.Hidden || !leaf.Runnable() {
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
}
tool := tools[canonical]
if tool == nil {
t.Errorf("public %s missing from delivery Schema surface", canonical)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("%s availability=%q, want available", canonical, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("%s missing composite adapter reason", canonical)
}
if tool["interface_ref"] != nil {
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
}
}
}
-123
View File
@@ -828,12 +828,6 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
// The organization mirror may be absent for long-lived local logins
// that predate mirror publication. Recover from the legacy global
// slot before giving up.
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
return recovered, nil
}
return nil, rErr
}
if p.logger != nil {
@@ -897,123 +891,6 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
return refreshed, nil
}
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
var exchangeErr *MCPTokenExchangeError
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
return nil, refreshErr
}
if selected == nil {
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
"corp_id", strings.TrimSpace(selected.CorpID),
"user_id", strings.TrimSpace(selected.UserID),
"refresh_error_code", exchangeErr.Code,
)
legacy, loadErr := tokenLoadKeychain()
if loadErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
return nil, refreshErr
}
if legacy == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
return nil, refreshErr
}
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
"step", "candidate_mismatch",
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
"legacy_user_id", strings.TrimSpace(legacy.UserID),
)
return nil, refreshErr
}
recovered := *legacy
if strings.TrimSpace(recovered.UserID) == "" {
recovered.UserID = strings.TrimSpace(selected.UserID)
}
if strings.TrimSpace(recovered.UserName) == "" {
recovered.UserName = strings.TrimSpace(selected.UserName)
}
if recovered.IsAccessTokenValid() {
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
return &recovered, nil
}
if !recovered.IsRefreshTokenValid() {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
return nil, refreshErr
}
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
return nil, refreshErr
}
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
return nil, refreshErr
}
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
if recoverErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
return refreshed, nil
}
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
if selected == nil || legacy == nil {
return false
}
selectedCorpID := strings.TrimSpace(selected.CorpID)
legacyCorpID := strings.TrimSpace(legacy.CorpID)
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
return false
}
selectedUserID := strings.TrimSpace(selected.UserID)
legacyUserID := strings.TrimSpace(legacy.UserID)
if legacyUserID != "" {
return legacyUserID == selectedUserID
}
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
}
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
if strings.TrimSpace(corpID) == "" {
return false
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil || cfg == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
return false
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "multi_profile",
"corp_id", strings.TrimSpace(corpID),
"profile_count", len(profiles),
)
return false
}
profile := profiles[0]
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
return true
}
profileUserID := ""
if profile != nil {
profileUserID = strings.TrimSpace(profile.UserID)
}
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "identity_mismatch",
"selected_user_id", strings.TrimSpace(userID),
"profile_user_id", profileUserID,
)
return false
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
-517
View File
@@ -15,7 +15,6 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -190,195 +189,6 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "V1039 User",
CorpID: selected.CorpID,
UserID: selected.UserID,
UserName: selected.UserName,
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
var saved *TokenData
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
copy := *data
saved = &copy
return nil
})
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
}
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
}
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-multi",
UserID: "user-v1039-a",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-blank-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-user-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: "user-v1039-other",
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token owned by a different user")
}
}
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
expired := *fixture.blankToken
@@ -443,330 +253,3 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
}
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-single-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "Other User",
CorpID: selected.CorpID,
UserID: "user-v1039-other",
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-legacy-refresh",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
refreshed := &TokenData{
AccessToken: "refreshed-legacy-access",
RefreshToken: "rotated-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
refreshCalls := 0
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls++
if refreshCalls == 1 {
return nil, rejection
}
return refreshed, nil
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if refreshCalls != 2 {
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
}
if recovered.AccessToken != refreshed.AccessToken {
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
}
if recovered.RefreshToken != refreshed.RefreshToken {
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-profiles-error",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "shared-rejected-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-same-refresh",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: selected.RefreshToken,
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
provider := NewOAuthProvider(t.TempDir(), nil)
// nil selected must be rejected before any dereference.
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil selected error = %v, want original rejection", err)
}
// A keychain load that returns (nil, nil) must be rejected before any dereference.
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
selected := &TokenData{
CorpID: "corp-v1039-nil-legacy",
UserID: "user-v1039",
Source: "mcp",
}
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil legacy error = %v, want original rejection", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
provider := NewOAuthProvider(t.TempDir(), nil)
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
plainErr := errors.New("plain refresh failure")
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
t.Fatalf("plain error = %v, want original plain failure", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
selected := &TokenData{
CorpID: "corp-v1039-recovery-steps",
UserID: "user-v1039",
Source: "mcp",
}
t.Run("save_failure", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "valid-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("save failure error = %v, want original rejection", err)
}
})
t.Run("refresh_expired", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "expired-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(-time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("expired refresh error = %v, want original rejection", err)
}
})
t.Run("refresh_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, errors.New("legacy refresh failed")
})
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("legacy refresh error = %v, want original rejection", err)
}
})
t.Run("preflight_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("preflight error = %v, want original rejection", err)
}
})
}
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
configDir := t.TempDir()
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
t.Fatal("nil legacy accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
t.Fatal("different corp accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
t.Fatal("blank selected corp accepted")
}
blankSelected := &TokenData{CorpID: selected.CorpID}
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
})
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
t.Fatal("both blank user IDs should match only through the single-profile guard")
}
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
t.Fatal("blank selected with non-blank legacy accepted")
}
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
t.Fatal("blank corp accepted by single-profile check")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -10
View File
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
}
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
if parameters[flagName]["required"] != false {
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
}
@@ -315,15 +315,6 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
if parameters["max-depth"]["type"] != "integer" {
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
}
if got := parameters["version"]["property"]; got != "historyVersion" {
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
}
if parameters["version"]["type"] != "integer" {
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
}
if got := parameters["password"]["property"]; got != "password" {
t.Fatalf("doc read --password property = %#v, want password", got)
}
}
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
@@ -76,7 +76,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"agoal scorecard detail",
"agoal scorecard entity-detail",
"agoal scorecard update",
"agoal scorecard search-entities",
"agoal strategy detail",
"agoal strategy list",
"agoal strategy update",
+1 -8
View File
@@ -96,14 +96,7 @@ func init() {
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src"}},
})
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
})
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
registerRequireOneOf("sheet.import", "folder-token", "workspace")
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
@@ -408,7 +408,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
@@ -477,7 +476,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -554,7 +552,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
@@ -647,7 +644,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -668,7 +664,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
-18
View File
@@ -151,24 +151,6 @@ func WithOperation(operation string) Option {
}
}
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
// typed framework confirmation-gate failure carrying reason
// confirmation_required. Downstream classifiers must pass such errors through
// verbatim: the "re-run with --yes" semantics can only be carried by the
// machine-readable reason, while message-text classification actively
// misroutes them (a command path containing "permission" would be reported as
// an auth failure, and any other wording degrades to an unclassified error).
func IsConfirmationRequired(err error) bool {
if err == nil {
return false
}
var typed *Error
if stderrors.As(err, &typed) {
return strings.TrimSpace(typed.Reason) == "confirmation_required"
}
return false
}
// WithServerKey records the server identifier associated with the failure.
func WithServerKey(serverKey string) Option {
return func(err *Error) {
-32
View File
@@ -15,7 +15,6 @@ package errors
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -465,34 +464,3 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
t.Parallel()
if IsConfirmationRequired(nil) {
t.Fatal("nil error must not report confirmation_required")
}
if IsConfirmationRequired(NewValidation("missing required flag")) {
t.Fatal("plain validation error must not report confirmation_required")
}
plain := stderrors.New("需要用户确认")
if IsConfirmationRequired(plain) {
t.Fatal("message text alone must not report confirmation_required")
}
confirmation := NewValidation(
"blocked",
WithReason("confirmation_required"),
)
if !IsConfirmationRequired(confirmation) {
t.Fatal("typed confirmation error must report confirmation_required")
}
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
wrapped := fmt.Errorf("call tool: %w", confirmation)
if !IsConfirmationRequired(wrapped) {
t.Fatal("wrapped confirmation error must report confirmation_required")
}
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
if IsConfirmationRequired(otherReason) {
t.Fatal("other reasons must not report confirmation_required")
}
}
+1 -32
View File
@@ -31,7 +31,6 @@ func newAgoalCommand() *cobra.Command {
dws agoal scorecard detail 获取计分卡详情
dws agoal scorecard entity-detail 获取计分卡实体详情
dws agoal scorecard update 更新计分卡
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
dws agoal user rules 获取用户规则
dws agoal user objectives 查询用户目标列表
dws agoal report list-statistics 获取周月报数据跟催列表
@@ -360,37 +359,7 @@ scopeType 支持:
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardSearchContentCmd := &cobra.Command{
Use: "search-entities",
Short: "搜索计分卡指标与关键事项",
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
Example: ` dws agoal scorecard search-entities --keyword "业绩"
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
return err
}
toolArgs := map[string]any{
"keyword": mustGetFlag(cmd, "keyword"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
return callMCPTool("search_score_card_entities", toolArgs)
},
}
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
// ── user: 用户目标管理 ──────────────────────────────────────
-411
View File
@@ -8422,416 +8422,6 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
sectionMoveNodeCmd,
)
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
Short: "获取数据源表同步配置",
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_config", map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
})
},
}
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_config",
CanonicalPath: "aitable.datasource_get_config",
CLIPath: "aitable datasource get-config",
PrimaryCLIPath: "aitable datasource get-config",
},
Description: "获取数据源表的同步配置信息。",
Interface: aitableMCPInterface("get_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "获取数据源表的同步配置信息。",
UseWhen: []string{"查看已有数据源表的配置详情时"},
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
},
},
})
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceListSourcesCmd := &cobra.Command{
Use: "list-sources",
Short: "列出可用数据源来源",
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("list_datasource_sources", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
})
},
}
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_list_sources",
CanonicalPath: "aitable.datasource_list_sources",
CLIPath: "aitable datasource list-sources",
PrimaryCLIPath: "aitable datasource list-sources",
},
Description: "列出指定 Base 下可用的数据源条目。",
Interface: aitableMCPInterface("list_datasource_sources"),
Selection: contract.SelectionSpec{
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
},
},
})
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
validateJSONObject := func(flag, raw string) error {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
}
if _, ok := v.(map[string]any); !ok {
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
}
return nil
}
validateAutoSyncSetting := func(raw string) error {
return validateJSONObject("auto-sync-setting", raw)
}
datasourceGetFieldsCmd := &cobra.Command{
Use: "get-fields",
Short: "获取数据源可同步字段列表",
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
return callAitableTool("get_datasource_fields", map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
})
},
}
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_get_fields",
CanonicalPath: "aitable.datasource_get_fields",
CLIPath: "aitable datasource get-fields",
PrimaryCLIPath: "aitable datasource get-fields",
},
Description: "获取指定数据源来源的可同步字段列表。",
Interface: aitableMCPInterface("get_datasource_fields"),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
},
})
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
datasourceCreateCmd := &cobra.Command{
Use: "create",
Short: "创建数据源表并触发首次同步",
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
return err
}
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
auto, _ := cmd.Flags().GetBool("auto")
toolArgs := map[string]any{
"baseId": baseID,
"datasourceType": mustGetFlag(cmd, "datasource-type"),
"sourceConfig": mustGetFlag(cmd, "source-config"),
"auto": auto,
}
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
toolArgs["fieldIds"] = parseCSVValues(v)
}
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
return callAitableTool("create_datasource", toolArgs)
},
}
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_create",
CanonicalPath: "aitable.datasource_create",
CLIPath: "aitable datasource create",
PrimaryCLIPath: "aitable datasource create",
},
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
Interface: aitableMCPInterface("create_datasource"),
Selection: contract.SelectionSpec{
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新数据源表同步配置并触发同步",
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
}
if cmd.Flags().Changed("source-config") {
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
return err
}
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
}
if cmd.Flags().Changed("auto") {
auto, _ := cmd.Flags().GetBool("auto")
toolArgs["auto"] = auto
}
if cmd.Flags().Changed("field-ids") {
v := mustGetFlag(cmd, "field-ids")
if v == "" {
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
}
toolArgs["fieldIds"] = parseCSVValues(v)
}
if cmd.Flags().Changed("auto-sync-setting") {
v := mustGetFlag(cmd, "auto-sync-setting")
if v == "" {
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
}
if err := validateAutoSyncSetting(v); err != nil {
return err
}
toolArgs["autoSyncSetting"] = v
}
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
}
return callAitableTool("update_datasource_config", toolArgs)
},
}
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_update",
CanonicalPath: "aitable.datasource_update",
CLIPath: "aitable datasource update",
PrimaryCLIPath: "aitable datasource update",
},
Description: "更新已有数据源表的同步配置并触发一次同步。",
Interface: aitableMCPInterface("update_datasource_config"),
Selection: contract.SelectionSpec{
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
Examples: []string{
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "source-config", Property: "sourceConfig"},
{Name: "auto", Property: "auto"},
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
},
},
})
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
datasourceSyncCmd := &cobra.Command{
Use: "sync",
Short: "触发数据源表手动同步",
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
if len(tableIDs) < 1 || len(tableIDs) > 5 {
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
}
return callAitableTool("run_datasource_sync", map[string]any{
"baseId": baseID,
"tableIds": tableIDs,
})
},
}
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
Safety: aitableSafetyWrite(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync",
CanonicalPath: "aitable.datasource_sync",
CLIPath: "aitable datasource sync",
PrimaryCLIPath: "aitable datasource sync",
},
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
Interface: aitableMCPInterface("run_datasource_sync"),
Selection: contract.SelectionSpec{
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
},
},
})
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceSyncStatusCmd := &cobra.Command{
Use: "sync-status",
Short: "按任务 ID 查询数据源同步任务状态",
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
if len(ids) < 1 || len(ids) > 5 {
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"taskIds": ids,
}
return callAitableTool("get_datasource_sync_status", toolArgs)
},
}
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "datasource_sync_status",
CanonicalPath: "aitable.datasource_sync_status",
CLIPath: "aitable datasource sync-status",
PrimaryCLIPath: "aitable datasource sync-status",
},
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
Interface: aitableMCPInterface("get_datasource_sync_status"),
Selection: contract.SelectionSpec{
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
AvoidWhen: []string{"触发同步用 datasource sync"},
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
},
},
})
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
datasourceCmd.AddCommand(
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
datasourceCreateCmd, datasourceUpdateCmd,
datasourceSyncCmd, datasourceSyncStatusCmd,
)
// 组装 aitable 命令树
root.AddCommand(
baseCmd, tableCmd, fieldCmd,
@@ -8842,7 +8432,6 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
attachmentCmd, templateCmd,
advpermCmd,
sectionCmd,
datasourceCmd,
)
// 批量注册 --base 作为 --base-id 的隐藏别名
-462
View File
@@ -1,462 +0,0 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableDatasourceCaller struct {
calls []aitableTestCall
}
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
}}}, nil
}
func (*aitableDatasourceCaller) Format() string { return "json" }
func (*aitableDatasourceCaller) DryRun() bool { return false }
func (*aitableDatasourceCaller) Fields() string { return "" }
func (*aitableDatasourceCaller) JQ() string { return "" }
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableDatasourceCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
root := newAitableCommand()
root.SetArgs(append([]string{"datasource"}, args...))
return caller, root.Execute()
}
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids required", err)
}
}
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids error", err)
}
}
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5")
if err != nil {
t.Fatalf("5 table-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1")
if err != nil {
t.Fatalf("single table-id should succeed: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
}
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
if err != nil {
t.Fatalf("5 task-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "task-ids") {
t.Fatalf("error = %v, want task-ids required", err)
}
}
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-config",
"--base-id", "BASE123", "--table-id", "TBL456")
if err != nil {
t.Fatalf("get-config should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["tableId"] != "TBL456" {
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
}
}
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "list-sources",
"--base-id", "BASE123", "--datasource-type", "OA")
if err != nil {
t.Fatalf("list-sources should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
t.Fatalf("error = %v, want datasource-type required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("get-fields should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
}
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceCreateSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("create should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false when not provided", v)
}
}
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto")
if err != nil {
t.Fatalf("create with --auto should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
t.Fatalf("error = %v, want at least one config change required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when no changes provided")
}
}
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
if err != nil {
t.Fatalf("update with --auto only should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("update with source-config should succeed: %v", err)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
if _, ok := caller.calls[0].args["auto"]; ok {
t.Fatalf("auto should not be sent when --auto is omitted")
}
}
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
if err != nil {
t.Fatalf("update with --auto=false should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false", v)
}
}
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", raw)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected")
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
}
}
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
File diff suppressed because it is too large Load Diff
-788
View File
@@ -1,788 +0,0 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
cmd := newCollegeContactCommand()
if cmd.Name() != "college-contact" {
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
}
if !cmd.Hidden {
t.Error("extension root command should be Hidden")
}
// 分组 → 叶子命令映射
groups := map[string][]string{
"dept": {
"get-standard-structure", "get-detail", "get-chain", "search",
"create", "update", "delete", "batch-update-type", "overview",
},
"employee": {
"get-detail", "add", "remove", "change-type", "change-dept",
"send-active-sms", "list-employees", "list-unaccepted",
"list-unactive", "upgrade-status", "start-upgrade",
},
"alumni": {
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
},
"graduate": {
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
},
"group": {
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
},
}
for groupName, leaves := range groups {
var groupCmd *cobra.Command
for _, c := range cmd.Commands() {
if c.Name() == groupName {
groupCmd = c
break
}
}
if groupCmd == nil {
t.Fatalf("subcommand group %q not found", groupName)
}
for _, leaf := range leaves {
found := false
for _, c := range groupCmd.Commands() {
if c.Name() == leaf {
found = true
break
}
}
if !found {
t.Errorf("leaf command %q not found under %q", leaf, groupName)
}
}
}
// stats 分组已移除
for _, c := range cmd.Commands() {
if c.Name() == "stats" {
t.Error("subcommand group 'stats' should be removed")
}
}
}
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newCollegeContactCommand())
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
if err != nil {
t.Fatalf("command path not found: %v", err)
}
if c.Name() != "get-standard-structure" {
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
}
}
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newCollegeContactTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
return root
}
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
t.Helper()
root := newCollegeContactTestRoot()
root.SetArgs(args)
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
err = root.Execute()
return err, false
}
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if panicked {
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
}
if err == nil {
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
}
if !strings.Contains(err.Error(), "--yes") {
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
}
}
}
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if !panicked {
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
}
}
}
}
// withCollegeContactCaller installs a dry-run capture caller so happy-path
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
// without requiring a live MCP transport. In dry-run mode destructive
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactHappyPaths runs every leaf command with required flags only
// and with all optional flags populated, expecting a nil error (dry-run preview).
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure"},
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-detail", "--dept-id", "123"},
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-chain", "--dept-id", "123"},
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "delete", "--dept-id", "123"},
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
{"dept", "overview"},
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail", "--staff-id", "S1"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "remove", "--staff-ids", "S1"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "send-active-sms", "--dept-id", "100"},
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-employees", "--dept-id", "123"},
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unaccepted", "--dept-id", "123"},
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unactive", "--dept-id", "123"},
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "upgrade-status"},
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"employee", "start-upgrade"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
{"alumni", "get-info", "--alumni-dept-id", "123"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
{"alumni", "query", "--staff-id", "S1"},
{"alumni", "search", "--keyword", "x"},
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
{"alumni", "get-group", "--alumni-dept-id", "1"},
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
{"alumni", "create-group", "--alumni-dept-id", "1"},
{"alumni", "disband-group", "--alumni-dept-id", "1"},
{"alumni", "get-alumni-org-from-graduate"},
{"alumni", "create-alumni-org", "--org-name", "O"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-years"},
{"graduate", "query-graduate-depts", "--dept-id", "1"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
{"graduate", "get-task-result", "--request-no", "r1"},
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
{"graduate", "get-alumni-org"},
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
{"graduate", "search-graduate", "--keyword", "x"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "all-graduate", "--graduate-year", "2026"},
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule"},
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
{"group", "get-group-rule-schedule"},
{"group", "query-preview-data"},
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
{"group", "delete-group-rule", "--rule-id", "1"},
{"group", "enable-group-rule", "--rule-id", "1"},
{"group", "disable-group-rule", "--rule-id", "1"},
{"group", "set-group-rule-schedule"},
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
{"group", "execute-group-rule"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err != nil {
t.Errorf("%v: expected nil error, got: %v", args, err)
}
}
}
// TestCollegeContactValidationErrors exercises every validation-error branch:
// missing required flags, non-integer int flags, invalid bool flags, and
// empty-after-split CSV lists. Each case must return a non-nil error.
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure", "--dept-id", "abc"},
{"dept", "get-standard-structure", "--offset", "abc"},
{"dept", "get-standard-structure", "--size", "abc"},
{"dept", "get-detail"},
{"dept", "get-detail", "--dept-id", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
{"dept", "get-chain"},
{"dept", "get-chain", "--dept-id", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
{"dept", "search"},
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
{"dept", "search", "--dept-id", "1"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
{"dept", "create"},
{"dept", "create", "--super-id", "abc"},
{"dept", "create", "--super-id", "100"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
{"dept", "update"},
{"dept", "update", "--dept-id", "abc"},
{"dept", "update", "--dept-id", "1"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "delete"},
{"dept", "delete", "--dept-id", "abc"},
{"dept", "batch-update-type"},
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", "1,2"},
{"dept", "overview", "--dept-id", "abc"},
{"dept", "overview", "--offset", "abc"},
{"dept", "overview", "--size", "abc"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
{"employee", "add"},
{"employee", "add", "--emp-type", "x"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
{"employee", "remove"},
{"employee", "remove", "--staff-ids", ",,"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
{"employee", "change-type"},
{"employee", "change-type", "--staff-id", "S1"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
{"employee", "change-dept"},
{"employee", "change-dept", "--staff-id", "S1"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
{"employee", "send-active-sms"},
{"employee", "send-active-sms", "--dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-employees"},
{"employee", "list-employees", "--dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unaccepted"},
{"employee", "list-unaccepted", "--dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unactive"},
{"employee", "list-unactive", "--dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "upgrade-status", "--dept-id", "abc"},
{"employee", "upgrade-status", "--offset", "abc"},
{"employee", "upgrade-status", "--size", "abc"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree"},
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
{"alumni", "get-info"},
{"alumni", "get-info", "--alumni-dept-id", "abc"},
{"alumni", "list"},
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
{"alumni", "query"},
{"alumni", "search"},
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
{"alumni", "list-unaccepted"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
{"alumni", "get-group"},
{"alumni", "get-group", "--alumni-dept-id", "abc"},
{"alumni", "create-dept"},
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "create-dept", "--alumni-dept-id", "1"},
{"alumni", "update-dept"},
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1"},
{"alumni", "delete-dept"},
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
{"alumni", "update-managers"},
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
{"alumni", "update-managers", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
{"alumni", "add-alumnus"},
{"alumni", "add-alumnus", "--name", "X"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
{"alumni", "update-alumnus"},
{"alumni", "update-alumnus", "--staff-id", "S1"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
{"alumni", "remove-alumnus"},
{"alumni", "remove-alumnus", "--staff-id", "S1"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
{"alumni", "cancel-invite"},
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
{"alumni", "create-group"},
{"alumni", "create-group", "--alumni-dept-id", "abc"},
{"alumni", "disband-group"},
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
{"alumni", "create-alumni-org"},
{"alumni", "add-alumni-org-main-admins"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-depts"},
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
{"graduate", "query-graduate-sub-depts"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users"},
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
{"graduate", "get-task-result"},
{"graduate", "query-restore-sub-depts"},
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
{"graduate", "search-graduate"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
{"graduate", "commit-graduate"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
{"graduate", "all-graduate"},
{"graduate", "all-graduate", "--graduate-year", "abc"},
{"graduate", "batch-graduate"},
{"graduate", "batch-graduate", "--dept-id", "abc"},
{"graduate", "batch-graduate", "--dept-id", "1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "delete-and-graduate"},
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
{"graduate", "delete-and-graduate", "--dept-id", "1"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-delete-pending"},
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
{"graduate", "batch-delete-pending", "--dept-id", "1"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending"},
{"graduate", "batch-update-pending", "--dept-id", "abc"},
{"graduate", "batch-update-pending", "--dept-id", "1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
{"graduate", "commit-restore"},
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule", "--offset", "abc"},
{"group", "query-group-rule", "--size", "abc"},
{"group", "query-preview-data", "--offset", "abc"},
{"group", "query-preview-data", "--size", "abc"},
{"group", "create-group-rule"},
{"group", "create-group-rule", "--name", "X"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
{"group", "delete-group-rule"},
{"group", "delete-group-rule", "--rule-id", "abc"},
{"group", "enable-group-rule"},
{"group", "enable-group-rule", "--rule-id", "abc"},
{"group", "disable-group-rule"},
{"group", "disable-group-rule", "--rule-id", "abc"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err == nil {
t.Errorf("%v: expected non-nil error, got nil", args)
}
}
}
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
// user_required destructive leaf in a paired manner:
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
// productID, tool name, and complete argument payload.
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
type destructiveCase struct {
name string
args []string
wantTool string
wantInput map[string]any
}
cases := []destructiveCase{
{
"dept delete",
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
"delete_college_contact_dept",
map[string]any{"deptId": int64(123)},
},
{
"employee remove",
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
"remove_employee",
map[string]any{"staffIds": []string{"S1", "S2"}},
},
{
"alumni delete-dept",
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
"delete_alumni_dept",
map[string]any{"alumniDeptId": int64(1)},
},
{
"alumni remove-alumnus",
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
"delete_alumnus",
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
},
{
"alumni cancel-invite",
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
"delete_alumni_invite_record",
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"alumni disband-group",
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
"disband_alumni_group",
map[string]any{"alumniDeptId": int64(1)},
},
{
"graduate commit-graduate",
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
"commit_graduate",
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
},
{
"graduate all-graduate",
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
"all_graduate",
map[string]any{"graduateYear": int64(2026)},
},
{
"graduate batch-graduate",
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate delete-and-graduate",
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"delete_and_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-delete-pending",
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_delete_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-update-pending",
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
"batch_update_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
},
{
"graduate commit-restore",
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
"commit_restore",
map[string]any{"graduateDeptIds": []int64{1, 2}},
},
{
"group delete-group-rule",
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
"delete_group_rule",
map[string]any{"ruleId": int64(1)},
},
{
"group execute-group-rule",
[]string{"college-contact", "group", "execute-group-rule"},
"execute_group_rule",
map[string]any{},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
argsWithYes := append(append([]string{}, tc.args...), "--yes")
root.SetArgs(argsWithYes)
err := root.Execute()
if err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "college-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
// verify the productID, tool name, and args passed to callMCPToolOnServer.
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactDispatch verifies that representative commands from each
// group dispatch to the correct MCP tool with the expected productID and args.
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
type dispatchCase struct {
name string
args []string
wantTool string
wantProd string
checkArgs func(t *testing.T, args map[string]any)
}
cases := []dispatchCase{
{
name: "dept get-standard-structure",
args: []string{"college-contact", "dept", "get-standard-structure"},
wantTool: "get_college_standard_structure",
wantProd: "college-contact",
},
{
name: "dept get-detail",
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
wantTool: "get_college_dept_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
{
name: "dept create",
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
wantTool: "create_college_contact_dept",
wantProd: "college-contact",
},
{
name: "employee get-detail",
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
wantTool: "get_employee_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["staffId"] != "S1" {
t.Errorf("staffId = %v, want S1", input["staffId"])
}
},
},
{
name: "alumni get-dept-tree",
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
wantTool: "get_alumni_dept_tree",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["alumniDeptId"] != int64(123) {
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
}
},
},
{
name: "graduate query-graduate-years",
args: []string{"college-contact", "graduate", "query-graduate-years"},
wantTool: "query_graduate_years",
wantProd: "college-contact",
},
{
name: "group query-group-rule",
args: []string{"college-contact", "group", "query-group-rule"},
wantTool: "query_group_rule",
wantProd: "college-contact",
},
{
name: "dept delete with --yes",
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
wantTool: "delete_college_contact_dept",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := withCollegeContactDispatchCaller(t)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if caller.productID != tc.wantProd {
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
}
if caller.tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
}
if tc.checkArgs != nil {
tc.checkArgs(t, caller.args)
}
})
}
}
@@ -10,8 +10,6 @@ import (
"runtime"
"sync"
"testing"
"github.com/spf13/cobra"
)
const (
@@ -41,16 +39,7 @@ func TestMain(m *testing.M) {
if os.Getenv(helpersShellStubEnv) == "1" {
os.Exit(runHelpersShellStub())
}
// Cobra's Windows pre-exec hook walks the process table on every Execute*
// call to detect Explorer launches. Helpers tests execute command trees
// thousands of times, and none of those in-process invocations can be an
// Explorer launch, so keep that production-only check out of the test
// process. This also prevents every new exhaustive harness from having to
// remember a test-local override.
originalMousetrapHelpText := cobra.MousetrapHelpText
cobra.MousetrapHelpText = ""
code := m.Run()
cobra.MousetrapHelpText = originalMousetrapHelpText
if helpersShellStubBaseDir != "" {
_ = os.RemoveAll(helpersShellStubBaseDir)
}
@@ -504,147 +504,3 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
})
}
}
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
root := build()
os.Args = append([]string{"dws", root.Name()}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
// destructive confirmation gate on the three batch remove entry points
// (drive/doc permission remove, wiki member remove). Removing up to 30
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
// departments, chats, or role groups, so every invocation — new --members
// format and legacy --users alike — must confirm first: without --yes the
// command fails with the typed confirmation_required error and performs zero
// MCP calls; with --yes it dispatches exactly one call with the complete,
// precise tool arguments; --dry-run previews without any call.
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
cases := []struct {
name string
build func() *cobra.Command
args []string
product string
tool string
wantArgs map[string]any
}{
{
name: "drive permission remove --members",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{
{"type": "CONVERSATION", "id": "cid1"},
{"type": "TAG", "id": "t1", "corpId": "c1"},
},
},
},
{
name: "drive permission remove --users",
build: newDriveCommand,
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"userIds": []string{"uid1", "uid2"},
},
},
{
name: "doc permission remove --members",
build: newDocCommand,
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
product: "doc",
tool: "remove_permission",
wantArgs: map[string]any{
"nodeId": "n1",
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --members",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
},
},
{
name: "wiki member remove --users",
build: newWikiCommand,
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
product: "wiki",
tool: "remove_member",
wantArgs: map[string]any{
"workspaceId": "ws1",
"userIds": []string{"uid1"},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
caller := &guardedMutationCaller{}
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
}
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
confirmed := &guardedMutationCaller{}
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
t.Fatalf("confirmed remove returned error: %v", err)
}
if len(confirmed.calls) != 1 {
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
}
call := confirmed.calls[0]
if call.productID != tc.product || call.toolName != tc.tool {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
}
if !reflect.DeepEqual(call.args, tc.wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
}
// 3) --dry-run 预览:不触发任何 MCP 调用。
dryRun := &guardedMutationCaller{dryRun: true}
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
t.Fatalf("dry-run remove returned error: %v", err)
}
if len(dryRun.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
}
})
}
}
+59 -273
View File
@@ -1429,14 +1429,9 @@ func newDocCommand() *cobra.Command {
readCmd := &cobra.Command{
Use: "read",
Short: "读取文档内容 (Markdown)",
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
Example: ` dws doc read --node DOC_ID
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
dws doc read --node DOC_ID --version 3`,
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -1446,15 +1441,6 @@ func newDocCommand() *cobra.Command {
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
return err
}
password, _ := cmd.Flags().GetString("password")
historyVersion := 0
historyVersionSet := cmd.Flags().Changed("version")
if historyVersionSet {
historyVersion, _ = cmd.Flags().GetInt("version")
if historyVersion < 0 {
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
}
}
format, _ := cmd.Flags().GetString("content-format")
scope, _ := cmd.Flags().GetString("scope")
tags, _ := cmd.Flags().GetString("tags")
@@ -1495,18 +1481,15 @@ func newDocCommand() *cobra.Command {
startBlockID,
endBlockID,
outputPath,
password,
historyVersion,
historyVersionSet,
)
}
if format == "jsonml" {
outputPath, _ := cmd.Flags().GetString("output")
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
return runDocReadJsonML(cmd, nodeID, outputPath)
}
toolArgs := map[string]any{"nodeId": nodeID}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
return callMCPTool("get_document_content", toolArgs)
return callMCPTool("get_document_content", map[string]any{
"nodeId": nodeID,
})
},
}
DeclareLeafMetadata(readCmd, LeafSpec{
@@ -1533,8 +1516,6 @@ func newDocCommand() *cobra.Command {
UseWhen: []string{
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
},
AvoidWhen: []string{
@@ -1552,11 +1533,9 @@ func newDocCommand() *cobra.Command {
{Name: "content-format", Property: "format", Required: boolPtr(false)},
{Name: "end-block-id", Required: boolPtr(false)},
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
{Name: "password", Property: "password", Required: boolPtr(false)},
{Name: "scope", Required: boolPtr(false)},
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
},
},
})
@@ -2701,8 +2680,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
@@ -3556,20 +3533,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
permissionAddCmd := &cobra.Command{
Use: "add",
Short: "添加文档协作者",
Args: cobra.NoArgs,
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色(大小写不敏感):
支持的角色 (--role)(必须大写):
MANAGER 管理员,可读写、管理成员
EDITOR 编辑者,可查看、编辑、上传内容
DOWNLOADER 查看下载者,可查看并下载内容
@@ -3577,46 +3545,29 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
注意:
- OWNER 角色不可通过此接口添加。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 单次请求最多 30 个成员,超出请分批调用。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3653,9 +3604,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3664,31 +3613,18 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionAddCmd.Flags().String("user", "", "")
_ = permissionAddCmd.Flags().MarkHidden("user")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
permissionUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新文档协作者权限",
Args: cobra.NoArgs,
Long: `更新指定节点已有协作者的权限角色。
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色 (--role)(大小写不敏感):
支持的角色 (--role)(必须大写):
MANAGER 管理员
EDITOR 编辑者
DOWNLOADER 查看下载者
@@ -3698,43 +3634,26 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
- OWNER 角色不可通过此接口变更。
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3768,9 +3687,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3779,13 +3696,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
})
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionUpdateCmd.Flags().String("user", "", "")
_ = permissionUpdateCmd.Flags().MarkHidden("user")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
permissionListCmd := &cobra.Command{
Use: "list",
@@ -3793,14 +3708,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Short: "查询文档协作者列表",
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
Example: ` dws doc permission list --node DOC_ID
dws doc permission list --node DOC_ID --limit 50
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
dws doc permission list --node DOC_ID --limit 100
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -3809,13 +3721,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
toolArgs := map[string]any{
"nodeId": nodeID,
}
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
if limit > 0 {
toolArgs["maxResults"] = limit
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -3853,75 +3766,50 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "limit", Property: "maxResults"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
permissionListCmd.Flags().Int("max-results", 0, "")
_ = permissionListCmd.Flags().MarkHidden("max-results")
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
permissionRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除文档协作者权限",
Long: `从指定节点移除一个或多个协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
注意:
- OWNER 角色不可通过此接口移除。
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
- 单次请求最多 30 个成员,超出请分批调用。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws doc permission remove --node DOC_ID --users uid1
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -3931,11 +3819,8 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
}
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -3958,7 +3843,6 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -3966,10 +3850,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
},
})
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
permissionRemoveCmd.Flags().String("user", "", "")
_ = permissionRemoveCmd.Flags().MarkHidden("user")
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
// permission 子命令的 --node 隐藏别名
@@ -4869,30 +4752,16 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
}
}
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
// 显式 --version 0 表示读取文档初始版本。
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
if password != "" {
args["password"] = password
}
if historyVersionSet && historyVersion >= 0 {
args["historyVersion"] = historyVersion
}
}
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
toolArgs := map[string]any{
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
"nodeId": nodeID,
"format": "jsonml",
}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
})
if err != nil {
return err
}
@@ -4944,7 +4813,7 @@ func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, h
// runDocReadScope calls get_document_content with JSONML filtering parameters
// and preserves the returned read-only fragment container.
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -4964,7 +4833,6 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
if endBlockID != "" && scope == "range" {
args["endBlockId"] = endBlockID
}
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
if err != nil {
@@ -5293,85 +5161,3 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
}
return userIds, nil
}
// collectMembers parses the --members JSON array flag (new format), returning
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
// member types, each carrying an independent roleId.
//
// When onlyTypeID is true (remove operations), roleId is not required —
// only type and id are needed.
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
raw := mustGetFlag(cmd, "members")
if raw == "" {
return nil, nil
}
var members []map[string]any
if err := json.Unmarshal([]byte(raw), &members); err != nil {
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
}
if len(members) == 0 {
return nil, fmt.Errorf("--members 不能为空数组")
}
if len(members) > 30 {
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
}
for i, m := range members {
mt, ok := m["type"].(string)
if !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
}
if _, ok := m["id"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
}
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
}
if !onlyTypeID {
if _, ok := m["roleId"].(string); !ok {
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
}
if r, ok := m["roleId"].(string); ok {
m["roleId"] = normalizePermissionRole(r)
}
}
}
return members, nil
}
// validateMembersExclusivity ensures --members (new format) and --users (legacy
// format) are not used simultaneously. Exactly one must be provided.
func validateMembersExclusivity(cmd *cobra.Command) error {
hasMembers := mustGetFlag(cmd, "members") != ""
hasUsers := flagOrFallback(cmd, "users", "user") != ""
if hasMembers && hasUsers {
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
}
if !hasMembers && !hasUsers {
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
}
if hasMembers && mustGetFlag(cmd, "role") != "" {
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
}
return nil
}
// permissionPageSizeFromFlags resolves and validates the page size for the
// permission / member list commands. Both --limit and the hidden
// --max-results alias map to the server pageSize, whose accepted range is
// 1..50 (the backend rejects pageSize > 50 with
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
// It returns (size, true, nil) when a page size was explicitly provided.
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
for _, name := range []string{"limit", "max-results"} {
if !cmd.Flags().Changed(name) {
continue
}
size, _ := cmd.Flags().GetInt(name)
if size < 1 || size > 50 {
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
}
return size, true, nil
}
return 0, false, nil
}
+1 -1
View File
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
installScriptedCaller(t, caller)
err := runDocReadJsonML("node", tc.output, "", 0, false)
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
if tc.wantFail && err == nil {
t.Fatal("expected failure")
}
+5 -95
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
if err != nil || len(remaining) != 0 {
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
}
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
if read.Flags().Lookup(name) == nil {
t.Fatalf("doc read missing --%s", name)
}
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := installDocReadScopeCaller(t, tt.caller)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
var receipt map[string]any
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
t.Run("human output reports missing fragment", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "未匹配到节点") {
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
@@ -312,93 +312,3 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
}
})
}
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
// 本地校验阶段被拒绝、不产生远端调用。
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
t.Run("markdown path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("scope path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
if err := executeDocReadScopeCommand(t, caller,
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
"--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("negative history version is rejected locally", func(t *testing.T) {
for _, raw := range []string{"-1", "-3"} {
caller := &docReadScopeCaller{}
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
}
if len(caller.calls) != 0 {
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
}
}
})
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("unset history version is not forwarded", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1"}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
}
+46 -264
View File
@@ -2042,55 +2042,27 @@ func newDriveCommand() *cobra.Command {
drivePermAddCmd := &cobra.Command{
Use: "add",
Short: "添加协作者",
Args: cobra.NoArgs,
Long: `为文档空间节点添加协作成员并授予指定角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2131,9 +2103,7 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2141,64 +2111,35 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermAddCmd.Flags().String("user", "", "")
_ = drivePermAddCmd.Flags().MarkHidden("user")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
drivePermUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新协作者权限",
Args: cobra.NoArgs,
Long: `更新文档空间节点已有协作者的权限角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2235,9 +2176,7 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2245,39 +2184,33 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermUpdateCmd.Flags().String("user", "", "")
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
drivePermListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询协作者列表",
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount、hasMore 和 nextToken。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
Long: `查询文档空间节点的协作者列表。`,
Example: ` dws drive permission list --node DOC_ID
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
if limit > 0 {
toolArgs["maxResults"] = limit
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -2318,184 +2251,38 @@ func newDriveCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "limit", Property: "maxResults"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermGetSettingCmd := &cobra.Command{
Use: "get-setting",
Short: "查询节点权限设置",
Long: `查询文档空间节点的权限设置,返回三部分配置:
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
- shareScope: 分享范围(可见范围、链接分享设置)
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
查询协作者列表请改用 permission list。`,
Example: ` dws drive permission get-setting --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_permission_setting",
CanonicalPath: "drive.get_permission_setting",
CLIPath: "drive permission get-setting",
PrimaryCLIPath: "drive permission get-setting",
},
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"节点权限设置(权限模式/分享范围/权限策略)",
"properties":{
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
"shareScope":{
"type":"object",
"description":"分享范围设置",
"properties":{
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"linkShare":{
"type":"object",
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
"properties":{
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
},
"additionalProperties":true
}
},
"additionalProperties":true
},
"policies":{
"type":"array",
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
"items":{
"type":"object",
"description":"权限策略项",
"properties":{
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
"disabledValues":{
"type":"array",
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
"items":{
"type":"object",
"properties":{
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
},
"required":["value"],
"additionalProperties":true
}
},
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
},
"required":["code","name","description","disabledValues"],
"additionalProperties":true
}
}
},
"required":["docUrl","nodeId","shareScope","policies"],
"additionalProperties":true
}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
AvoidWhen: []string{
"查协作者清单用 permission list",
"查可申请角色与审批人用 permission apply-info",
},
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除协作者权限",
Long: `从文档空间节点移除协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
Long: `从文档空间节点移除协作成员的权限。`,
Example: ` dws drive permission remove --node DOC_ID --users uid1
dws drive permission remove --node DOC_ID --users uid1,uid2
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
dws drive permission remove --node DOC_ID --users uid1,uid2`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
toolArgs := map[string]any{
"nodeId": nodeID,
"userIds": userIds,
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
@@ -2505,11 +2292,8 @@ func newDriveCommand() *cobra.Command {
}
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -2535,7 +2319,6 @@ func newDriveCommand() *cobra.Command {
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -2543,14 +2326,13 @@ func newDriveCommand() *cobra.Command {
},
})
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
drivePermRemoveCmd.Flags().String("user", "", "")
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
@@ -2782,7 +2564,7 @@ func newDriveCommand() *cobra.Command {
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
@@ -1,237 +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.
package helpers
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
)
// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--node", "node-1")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want exactly one", caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("call = %#v", call)
}
if len(call.args) != 1 || call.args["nodeId"] != "node-1" {
t.Fatalf("args = %#v, want only nodeId=node-1", call.args)
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) {
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting", "--"+alias, "node-alias")
if err != nil {
t.Fatalf("alias --%s: %v", alias, err)
}
if len(caller.calls) != 1 {
t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls)
}
call := caller.calls[0]
if call.productID != "drive" || call.toolName != "get_permission_setting" {
t.Fatalf("alias --%s call = %#v", alias, call)
}
if call.args["nodeId"] != "node-alias" {
t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args)
}
}
}
func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"permission", "get-setting")
if err == nil || !strings.Contains(err.Error(), "flag --node is required") {
t.Fatalf("err = %v, want flag --node is required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls)
}
}
// ── drive permission get-setting:ResultSpec 返回值契约 ──
func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) {
drive := newDriveCommand()
leaf, _, err := drive.Find([]string{"permission", "get-setting"})
if err != nil || leaf == nil {
t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err)
}
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" {
t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok)
}
if final.Result == nil {
t.Fatal("get-setting final Result is nil")
}
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
}
var root map[string]any
if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil {
t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema)
}
assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies")
properties := resultSchemaProperties(t, final.Result.DataSchema)
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) {
t.Fatalf("result properties = %#v", got)
}
permissionMode, ok := properties["permissionMode"].(map[string]any)
if !ok {
t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"])
}
if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", "<null>"}) {
t.Fatalf("permissionMode enum = %#v", got)
}
if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"])
}
shareScope := schemaObjectProperty(t, properties, "shareScope")
shareScopeProperties := schemaProperties(t, shareScope)
if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) {
t.Fatalf("shareScope properties = %#v", got)
}
if _, ok := shareScope["required"]; ok {
t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"])
}
if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", "<null>"}) {
t.Fatalf("visibility enum = %#v", got)
}
if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", "<null>"}) {
t.Fatalf("defaultRole enum = %#v", got)
}
linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare"))
if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) {
t.Fatalf("linkShare properties = %#v", got)
}
policies, ok := properties["policies"].(map[string]any)
if !ok || policies["type"] != "array" {
t.Fatalf("policies = %#v, want array schema", properties["policies"])
}
policiesDescription, _ := policies["description"].(string)
for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} {
if !strings.Contains(policiesDescription, fragment) {
t.Fatalf("policies description missing %q: %s", fragment, policiesDescription)
}
}
items, ok := policies["items"].(map[string]any)
if !ok {
t.Fatalf("policies items = %#v", policies["items"])
}
assertSchemaRequired(t, items, "code", "name", "description", "disabledValues")
itemProperties := schemaProperties(t, items)
if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) {
t.Fatalf("policy item properties = %#v", got)
}
code, ok := itemProperties["code"].(map[string]any)
if !ok {
t.Fatalf("code = %#v", itemProperties["code"])
}
if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{
"external_share", "external_share_manager_only", "member_invite", "member_invite_org_only",
"comment", "permission_apply", "external_permission_apply", "watermark", "node_spread",
"online_content_copy", "node_move_forbidden", "node_spread_scope",
}) {
t.Fatalf("code enum = %#v", got)
}
value, ok := itemProperties["value"].(map[string]any)
if !ok {
t.Fatalf("value = %#v", itemProperties["value"])
}
valueDescription, _ := value["description"].(string)
for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} {
if !strings.Contains(valueDescription, fragment) {
t.Fatalf("value description missing %q: %s", fragment, valueDescription)
}
}
for _, field := range []string{"name", "description"} {
entry, ok := itemProperties[field].(map[string]any)
if !ok || entry["type"] != "string" {
t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field])
}
entryDescription, _ := entry["description"].(string)
if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") {
t.Fatalf("policy item %s description = %q", field, entryDescription)
}
}
disabledValues, ok := itemProperties["disabledValues"].(map[string]any)
if !ok || disabledValues["type"] != "array" {
t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"])
}
disabledValuesDescription, _ := disabledValues["description"].(string)
for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} {
if !strings.Contains(disabledValuesDescription, fragment) {
t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription)
}
}
disabledItems, ok := disabledValues["items"].(map[string]any)
if !ok {
t.Fatalf("disabledValues items = %#v", disabledValues["items"])
}
assertSchemaRequired(t, disabledItems, "value")
disabledItemProperties := schemaProperties(t, disabledItems)
if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) {
t.Fatalf("disabledValues item properties = %#v", got)
}
if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" {
t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"])
}
reason, ok := disabledItemProperties["reason"].(map[string]any)
if !ok {
t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"])
}
if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"])
}
allowedValues, ok := itemProperties["allowedValues"].(map[string]any)
if !ok {
t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"])
}
if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") {
t.Fatalf("allowedValues description = %q", allowedValues["description"])
}
}
func schemaEnumValues(t *testing.T, property map[string]any) []string {
t.Helper()
raw, ok := property["enum"].([]any)
if !ok {
t.Fatalf("schema enum = %#v, want array", property["enum"])
}
values := make([]string, 0, len(raw))
for _, value := range raw {
switch typed := value.(type) {
case string:
values = append(values, typed)
case nil:
values = append(values, "<null>")
default:
t.Fatalf("schema enum value = %#v, want string or null", value)
}
}
return values
}
File diff suppressed because it is too large Load Diff
-503
View File
@@ -1,503 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func runEduApp(t *testing.T, args ...string) error {
t.Helper()
cmd := newEduAppCommand()
cmd.SetArgs(args)
return cmd.Execute()
}
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
// populated, so all optional-field branches and the dispatch line are covered.
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
cases := [][]string{
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
{"report", "get", "--ids", "1001,1002"},
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
{"card", "end", "--card-id", "1"},
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
cases := [][]string{
{"report", "by-teacher", "--page", "0", "--limit", "0"},
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
{"student-list missing students", []string{"task", "student-list"}, "students"},
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
{"report get missing ids", []string{"report", "get"}, "ids"},
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
{"card end missing card-id", []string{"card", "end"}, "card-id"},
{"card list missing status", []string{"card", "list"}, "status"},
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduAppCaller(t)
err := runEduApp(t, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduAppParseCSV = %#v", got)
}
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduAppParseIntCSV invalid = nil error")
}
}
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_ai_message_summary_list" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != int64(100) {
t.Fatalf("classId = %v, want 100", input["classId"])
}
if input["cid"] != "cidxxx" {
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
if input["status"] != int64(1) {
t.Fatalf("status = %v, want 1", input["status"])
}
})
t.Run("report get dispatches get_report", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_report" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
ids, ok := input["schoolReportIdList"].([]int64)
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
}
})
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "query_student_circle_posts" {
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != "12345" {
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
}
if input["studentId"] != "stu1" {
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
})
t.Run("card end dispatches end_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "end_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(999) {
t.Fatalf("cardId = %v, want 999", input["cardId"])
}
})
t.Run("card update dispatches update_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "update_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(77) {
t.Fatalf("cardId = %v, want 77", input["cardId"])
}
if input["identifier"] != "org1-staff1-uuid" {
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
}
if input["title"] != "新标题" {
t.Fatalf("title = %v, want %q", input["title"], "新标题")
}
if input["shouldSendUpdateMsg"] != true {
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
}
})
}
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduAppConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduAppCommand())
return root
}
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"notice delete",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
"delete_notice",
map[string]any{"noticeId": int64(12345)},
},
{
"notice delete with user-name",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
"delete_notice",
map[string]any{"noticeId": int64(12345), "userName": "张三"},
},
{
"homework delete",
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
"delete_homework",
map[string]any{"homeworkId": int64(12345)},
},
{
"diploma delete",
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
"delete_diploma",
map[string]any{"diplomaId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-app" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
File diff suppressed because it is too large Load Diff
-672
View File
@@ -1,672 +0,0 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newTestEduContactRoot() *cobra.Command {
return newEduContactCommand()
}
// ──────────────────────────────────────────────────────────
// 命令注册测试 — 验证所有子命令路径是否正确注册
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
root := newTestEduContactRoot()
paths := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "class-list"},
// class — 原有
{"class", "detail"},
{"class", "students"},
{"class", "teachers"},
{"class", "same-name"},
{"class", "user-role"},
{"class", "search-by-name"},
{"class", "headmaster"},
// class — 新增
{"class", "search-by-teacher"},
{"class", "add-student"},
{"class", "add-teachers"},
{"class", "add-unofficial-student"},
{"class", "delete-students"},
{"class", "delete-teacher"},
{"class", "modify-student-info"},
{"class", "move-student"},
{"class", "update-info"},
{"class", "update-student"},
{"class", "update-student-mobile"},
{"class", "update-student-number"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course"},
}
for _, path := range paths {
if _, _, err := root.Find(path); err != nil {
t.Errorf("command path %v not found: %v", path, err)
}
}
}
// ──────────────────────────────────────────────────────────
// 参数校验测试 — 验证必填参数缺失时返回错误
// ──────────────────────────────────────────────────────────
func executeCommand(root *cobra.Command, args ...string) error {
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "search-by-teacher")
if err == nil {
t.Fatal("expected error for missing --name, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
if err == nil {
t.Fatal("expected error for invalid --is-adviser, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --origin-class-id, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --mobile, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-staff-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --student-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
if err == nil {
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --student-number, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
if err == nil {
t.Fatal("expected error for --group-name without --conversation-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
if err == nil {
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
if err == nil {
t.Fatal("expected error for missing --student-name, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
if err == nil {
t.Fatal("expected error for missing mobile (student or parent), got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
if err == nil {
t.Fatal("expected error for invalid --mother JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course")
if err == nil {
t.Fatal("expected error for missing --teacher-class-infos, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
if err == nil {
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
if err == nil {
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
}
}
// ──────────────────────────────────────────────────────────
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
// ──────────────────────────────────────────────────────────
// withEduContactCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
withEduContactCaller(t)
cases := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "stats", "--statistics-type", "1"},
{"school", "class-list"},
// class — 读操作
{"class", "detail", "--dept-id", "123"},
{"class", "students", "--dept-id", "123"},
{"class", "teachers", "--dept-id", "123"},
{"class", "same-name", "--dept-id", "123"},
{"class", "user-role", "--dept-id", "123"},
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
{"class", "headmaster", "--class-name", "一年级1班"},
{"class", "search-by-teacher", "--name", "张老师"},
// class — update-student 各分支
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
// class — add-student 各手机号来源分支
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--student-mobile", "13800138000", "--student-user-id", "u1",
"--student-number", "S1", "--virtual-account-id", "v1"},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--mother", `{"mobile":"13800138000","relation":"M"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--father", `{"mobile":"13900139000","relation":"F"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
// class — modify-student-info 两个分支
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
"--patriarch-user-id", "p1", "--relation", "父亲"},
// class — delete-teacher
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
// class — update-info 三个可选分支
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
// class — update-student-number
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
// class — add-unofficial-student
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
// class — delete-students
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
// class — update-student-mobile
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
// class — move-student
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
// class — add-teachers 默认/班主任
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
}
for _, args := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, args...); err != nil {
t.Errorf("happy path %v returned error: %v", args, err)
}
}
}
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
// the existing error tests, ensuring 100% changed-code coverage.
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
cases := []struct {
name string
args []string
}{
// eduRequiredIntFlag:空值 + 非整数
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
{"detail-missing-dept", []string{"class", "detail"}},
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
{"students-missing-dept", []string{"class", "students"}},
{"teachers-missing-dept", []string{"class", "teachers"}},
{"samename-missing-dept", []string{"class", "same-name"}},
{"userrole-missing-dept", []string{"class", "user-role"}},
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
{"headmaster-missing-classname", []string{"class", "headmaster"}},
// update-student
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
// add-student
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
// modify-student-info
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
// delete-teacher
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
// update-info
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
// update-student-number
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
// add-unofficial-student
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
// delete-students
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
// update-student-mobile
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
// move-student
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
// add-teachers
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
}
for _, tc := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, tc.args...); err == nil {
t.Errorf("%s: expected error, got nil", tc.name)
}
}
}
// ──────────────────────────────────────────────────────────
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
// ──────────────────────────────────────────────────────────
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
// the dispatched productID, tool name, and args.
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "get_class_detail" {
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(123) {
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
})
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "query_class_by_guardian_name" {
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["queryType"] != "student" {
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
}
if input["name"] != "张三" {
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
}
})
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "statistics_school" {
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["statisticsType"] != int64(2) {
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
}
})
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "batch_add_class_teacher" {
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(789) {
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
}
teacherUserIds, ok := input["teacherUserIds"].([]string)
if !ok {
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
}
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
}
if input["isAdviser"] != int64(1) {
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
}
})
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "move_student" {
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
studentUserIds, ok := input["studentUserIds"].([]string)
if !ok {
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
}
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
}
if input["originClassId"] != int64(100) {
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
}
if input["targetClassId"] != int64(200) {
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
}
})
}
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduContactConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduContactCommand())
return root
}
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"class delete-teacher",
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
"delete_teacher",
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
},
{
"class delete-students",
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
"delete_students",
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
-605
View File
@@ -1,605 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-familygroup — 家庭群管理
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
// ──────────────────────────────────────────────────────────
func newEduFamilyGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-familygroup",
Selection: contract.ProductSelectionDecl{
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
UseWhen: []string{
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
},
AvoidWhen: []string{
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-familygroup",
Short: "家庭群",
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// group 子命令组 — 家庭群读操作
// ════════════════════════════════════════════════════════════
groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}
groupCheckExistsCmd := &cobra.Command{
Use: "check-exists",
Short: "检查家庭群是否存在",
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
"input": map[string]any{"uid": uid, "groupName": groupName},
})
},
}
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "check_family_group_exists",
CanonicalPath: "edu-familygroup.check_family_group_exists",
CLIPath: "edu-familygroup group check-exists",
PrimaryCLIPath: "edu-familygroup group check-exists",
},
Description: "检查家庭群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查家庭群是否存在",
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
Examples: []string{
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
},
},
})
groupListChildrenCmd := &cobra.Command{
Use: "list-children",
Short: "查询家长绑定的孩子列表",
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group list-children --uid 12345
dws edu-familygroup group list-children --uid 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
"input": map[string]any{"uid": uid},
})
},
}
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "listBoundChildren",
CanonicalPath: "edu-familygroup.listBoundChildren",
CLIPath: "edu-familygroup group list-children",
PrimaryCLIPath: "edu-familygroup group list-children",
},
Description: "查询家长绑定的孩子列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询家长绑定的孩子列表",
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
Examples: []string{
"dws edu-familygroup group list-children --uid 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// manage 子命令组 — 家庭群写操作
// ════════════════════════════════════════════════════════════
manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}
manageCreateCmd := &cobra.Command{
Use: "create",
Short: "创建家庭群",
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childrenRaw, _ := cmd.Flags().GetString("children")
if strings.TrimSpace(childrenRaw) == "" {
return fmt.Errorf("--children 为必填参数")
}
var children []any
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateChildren(children); err != nil {
return err
}
input := map[string]any{"uid": uid, "children": children}
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
var addGroup map[string]any
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
}
if addGroup == nil {
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
}
input["addGroup"] = addGroup
}
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
input["source"] = v
}
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "create_family_group",
CanonicalPath: "edu-familygroup.create_family_group",
CLIPath: "edu-familygroup manage create",
PrimaryCLIPath: "edu-familygroup manage create",
},
Description: "创建家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建家庭群并同步创建家庭组织",
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
Examples: []string{
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "children", Property: "input.children", Required: boolPtr(true)},
{Name: "add-group", Property: "input.addGroup"},
{Name: "source", Property: "input.source"},
},
},
})
manageInviteParentCmd := &cobra.Command{
Use: "invite-parent",
Short: "短信邀请家长加入家庭群",
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
})
},
}
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "invite_parent_to_familygroup",
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
CLIPath: "edu-familygroup manage invite-parent",
PrimaryCLIPath: "edu-familygroup manage invite-parent",
},
Description: "短信邀请家长加入家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
},
Selection: contract.SelectionSpec{
AgentSummary: "通过短信邀请家长加入家庭群",
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
Examples: []string{
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
},
},
})
manageAddChildCmd := &cobra.Command{
Use: "add-child",
Short: "为家庭群添加孩子",
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
mobile 与 students 至少传一个。
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
if err != nil {
return err
}
mobile, _ := cmd.Flags().GetString("mobile")
mobile = strings.TrimSpace(mobile)
studentsRaw, _ := cmd.Flags().GetString("students")
studentsRaw = strings.TrimSpace(studentsRaw)
if mobile == "" && studentsRaw == "" {
return fmt.Errorf("--mobile 与 --students 至少传一个")
}
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
if mobile != "" {
input["mobile"] = mobile
}
if studentsRaw != "" {
var students []any
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateStudents(students); err != nil {
return err
}
input["students"] = students
}
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "add_child_to_family_group",
CanonicalPath: "edu-familygroup.add_child_to_family_group",
CLIPath: "edu-familygroup manage add-child",
PrimaryCLIPath: "edu-familygroup manage add-child",
},
Description: "为家庭群添加孩子",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为已有家庭群添加孩子",
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
AvoidWhen: []string{"创建全新家庭群用 create"},
Examples: []string{
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "name", Property: "input.name", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile"},
{Name: "students", Property: "input.students"},
},
},
})
manageToggleAppCmd := &cobra.Command{
Use: "toggle-app",
Short: "开启或关闭学生应用权限",
Long: `为指定学生号开启或关闭应用权限。
支持的应用类型(--app-type):
- XIAOTIANDI:小天地(学生圈)
- LEARNING_VIDEO:学习视频
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
if err != nil {
return err
}
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
if err != nil {
return err
}
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
}
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
if err != nil {
return err
}
var open bool
switch strings.ToLower(openStr) {
case "true":
open = true
case "false":
open = false
default:
return fmt.Errorf("--open 须为 true 或 false")
}
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
"input": map[string]any{
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
"appType": appType, "open": open,
},
})
},
}
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "toggle_student_app",
CanonicalPath: "edu-familygroup.toggle_student_app",
CLIPath: "edu-familygroup manage toggle-app",
PrimaryCLIPath: "edu-familygroup manage toggle-app",
},
Description: "开启或关闭学生应用权限",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
},
Selection: contract.SelectionSpec{
AgentSummary: "开启或关闭学生应用权限",
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
Examples: []string{
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
{Name: "open", Property: "input.open", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// group(读操作)flags
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
// manage(写操作)flags
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
root.AddCommand(groupCmd, manageCmd)
return root
}
// eduFamilyGroupValidateChildren validates the --children payload: the array
// must be non-empty, each child must carry a non-empty name and a non-empty
// students array, and each student must carry corpId + staffId.
func eduFamilyGroupValidateChildren(children []any) error {
if len(children) == 0 {
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
}
for i, c := range children {
child, ok := c.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
}
name, _ := child["name"].(string)
if strings.TrimSpace(name) == "" {
return fmt.Errorf("--children[%d].name 为必填字段", i)
}
students, ok := child["students"].([]any)
if !ok || len(students) == 0 {
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
}
for j, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
}
corpID, _ := student["corpId"].(string)
if strings.TrimSpace(corpID) == "" {
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
}
staffID, _ := student["staffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
}
}
}
return nil
}
// eduFamilyGroupValidateStudents validates the --students payload: the array
// must be non-empty and each element must carry a numeric schoolOrgId and a
// non-empty studentStaffId.
func eduFamilyGroupValidateStudents(students []any) error {
if len(students) == 0 {
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
}
for i, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
}
switch student["schoolOrgId"].(type) {
case float64, int, int64, json.Number:
default:
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
}
staffID, _ := student["studentStaffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
}
}
return nil
}
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
// error if the flag is empty or not a valid integer.
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return "", fmt.Errorf("--%s 为必填参数", name)
}
return v, nil
}
-318
View File
@@ -1,318 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
)
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
{"group", "list-children", "--uid", "1"},
{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "XIAOTIANDI", "--open", "true"},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "LEARNING_VIDEO", "--open", "false"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "{"}, "add-group"},
{"create add-group null", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "null"}, "null"},
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
valid := []any{map[string]any{
"name": "小明",
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
}}
if err := eduFamilyGroupValidateChildren(valid); err != nil {
t.Fatalf("valid children error = %v", err)
}
cases := []struct {
name string
children []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
if err := eduFamilyGroupValidateStudents(valid); err != nil {
t.Fatalf("valid students error = %v", err)
}
cases := []struct {
name string
students []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
t.Run("check-exists", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "check_family_group_exists" {
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(123) {
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
}
if input["groupName"] != "测试家庭" {
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
}
})
t.Run("list-children", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "listBoundChildren" {
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(456) {
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
}
})
t.Run("create", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "create_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(789) {
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
}
if input["children"] == nil {
t.Fatalf("input[\"children\"] is nil, want non-nil")
}
})
t.Run("invite-parent", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "invite_parent_to_familygroup" {
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["orgId"] != int64(1) {
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
}
if input["uid"] != int64(2) {
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
}
if input["mobile"] != "13800138000" {
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
}
})
t.Run("toggle-app", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "toggle_student_app" {
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["appType"] != "XIAOTIANDI" {
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
}
if input["open"] != true {
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
}
})
t.Run("add-child", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
"--name", "小明", "--mobile", "13900139000",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "add_child_to_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
}
want := map[string]any{
"input": map[string]any{
"orgId": int64(12345),
"uid": int64(67890),
"name": "小明",
"mobile": "13900139000",
"students": []any{
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
},
},
}
if !reflect.DeepEqual(caller.args, want) {
t.Fatalf("args = %#v, want %#v", caller.args, want)
}
})
}
-846
View File
@@ -1,846 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-group — 家校群管理
// 共 14 个工具,按 student-group / class-group / batch 分组
// ──────────────────────────────────────────────────────────
func newEduGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-group",
Selection: contract.ProductSelectionDecl{
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
UseWhen: []string{
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
},
AvoidWhen: []string{
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-group",
Short: "家校群",
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// student-group 子命令组 — 师生群管理
// ════════════════════════════════════════════════════════════
studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}
studentGroupInfoCmd := &cobra.Command{
Use: "info",
Short: "查询班级师生群信息",
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group info --dept-id 12345
dws edu-group student-group info --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_group_info",
CanonicalPath: "edu-group.get_class_group_info",
CLIPath: "edu-group student-group info",
PrimaryCLIPath: "edu-group student-group info",
},
Description: "查询班级师生群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群信息",
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
Examples: []string{
"dws edu-group student-group info --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查组织是否已创建师生群",
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
Example: ` dws edu-group student-group exists --dept-id 12345
dws edu-group student-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_group_exists",
CanonicalPath: "edu-group.check_class_group_exists",
CLIPath: "edu-group student-group exists",
PrimaryCLIPath: "edu-group student-group exists",
},
Description: "检查组织是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查组织是否已创建师生群",
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
AvoidWhen: []string{"查询师生群成员用 student-group members"},
Examples: []string{
"dws edu-group student-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupMembersCmd := &cobra.Command{
Use: "members",
Short: "查询师生群成员列表",
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group members --dept-id 12345
dws edu-group student-group members --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_members",
CanonicalPath: "edu-group.get_group_members",
CLIPath: "edu-group student-group members",
PrimaryCLIPath: "edu-group student-group members",
},
Description: "查询师生群成员列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询师生群成员列表",
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
Examples: []string{
"dws edu-group student-group members --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupIsInCmd := &cobra.Command{
Use: "is-in",
Short: "判断用户是否在师生群中",
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group is-in --dept-id 12345
dws edu-group student-group is-in --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "is_in_class_group",
CanonicalPath: "edu-group.is_in_class_group",
CLIPath: "edu-group student-group is-in",
PrimaryCLIPath: "edu-group student-group is-in",
},
Description: "判断用户是否在师生群中",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "判断用户是否在师生群中",
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
AvoidWhen: []string{"查询群成员列表用 student-group members"},
Examples: []string{
"dws edu-group student-group is-in --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "查询班级群会话详情",
Long: `查询指定班级师生群的会话详情。
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group conversation --dept-id 12345
dws edu-group student-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_conversation_info",
CanonicalPath: "edu-group.get_group_conversation_info",
CLIPath: "edu-group student-group conversation",
PrimaryCLIPath: "edu-group student-group conversation",
},
Description: "查询班级群会话详情",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群会话详情",
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
Examples: []string{
"dws edu-group student-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupCreateCmd := &cobra.Command{
Use: "create",
Short: "创建班级师生群",
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group create --dept-id 12345
dws edu-group student-group create --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "create_class_group",
CanonicalPath: "edu-group.create_class_group",
CLIPath: "edu-group student-group create",
PrimaryCLIPath: "edu-group student-group create",
},
Description: "创建班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定班级创建师生群",
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
Examples: []string{
"dws edu-group student-group create --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupDisbandCmd := &cobra.Command{
Use: "disband",
Short: "解散班级师生群",
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group disband --dept-id 12345
dws edu-group student-group disband --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "disband_class_group",
CanonicalPath: "edu-group.disband_class_group",
CLIPath: "edu-group student-group disband",
PrimaryCLIPath: "edu-group student-group disband",
},
Description: "解散班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "解散班级师生群并删除关联关系",
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
AvoidWhen: []string{"查询师生群信息用 student-group info"},
Examples: []string{
"dws edu-group student-group disband --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// class-group 子命令组 — 班级群(家校群)会话管理
// ════════════════════════════════════════════════════════════
classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}
classGroupConversationIDCmd := &cobra.Command{
Use: "conversation-id",
Short: "获取班级群会话ID",
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation-id --dept-id 12345
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation_id",
CanonicalPath: "edu-group.get_class_conversation_id",
CLIPath: "edu-group class-group conversation-id",
PrimaryCLIPath: "edu-group class-group conversation-id",
},
Description: "获取班级群会话ID",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群会话ID",
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "获取班级群完整会话信息",
Long: `获取指定班级的班级群完整会话信息。
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation --dept-id 12345
dws edu-group class-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation",
CanonicalPath: "edu-group.get_class_conversation",
CLIPath: "edu-group class-group conversation",
PrimaryCLIPath: "edu-group class-group conversation",
},
Description: "获取班级群完整会话信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群完整会话信息",
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
Examples: []string{
"dws edu-group class-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查班级群是否存在",
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group exists --dept-id 12345
dws edu-group class-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_conversation_exists",
CanonicalPath: "edu-group.check_class_conversation_exists",
CLIPath: "edu-group class-group exists",
PrimaryCLIPath: "edu-group class-group exists",
},
Description: "检查班级群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查班级群是否存在",
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupListByConversationIDsCmd := &cobra.Command{
Use: "list-by-cids",
Short: "根据会话ID列表批量查询群信息",
Long: `根据群会话ID列表批量查询群的详细信息。
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("conversation-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--conversation-ids 为必填参数")
}
conversationIDs := eduGroupParseCSV(raw)
if len(conversationIDs) == 0 {
return fmt.Errorf("--conversation-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
"input": map[string]any{"conversationIds": conversationIDs},
})
},
}
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "list_groups_by_conversation_ids",
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
CLIPath: "edu-group class-group list-by-cids",
PrimaryCLIPath: "edu-group class-group list-by-cids",
},
Description: "根据会话ID列表批量查询群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
},
Selection: contract.SelectionSpec{
AgentSummary: "根据会话ID列表批量查询群信息",
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
Examples: []string{
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// batch 子命令组 — 批量操作
// ════════════════════════════════════════════════════════════
batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}
batchCheckClassGroupCmd := &cobra.Command{
Use: "check-student-group",
Short: "批量检查班级是否已创建师生群",
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs, err := eduGroupParseIntCSV(raw)
if err != nil {
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
}
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_check_class_group",
CanonicalPath: "edu-group.batch_check_class_group",
CLIPath: "edu-group batch check-student-group",
PrimaryCLIPath: "edu-group batch check-student-group",
},
Description: "批量检查班级是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量检查班级是否已创建师生群",
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
AvoidWhen: []string{"单个班级检查用 student-group exists"},
Examples: []string{
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchGetClassConversationsCmd := &cobra.Command{
Use: "get-class-groups",
Short: "批量获取班级群信息",
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs := eduGroupParseCSV(raw)
if len(classIDs) == 0 {
return fmt.Errorf("--class-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_get_class_conversations",
CanonicalPath: "edu-group.batch_get_class_conversations",
CLIPath: "edu-group batch get-class-groups",
PrimaryCLIPath: "edu-group batch get-class-groups",
},
Description: "批量获取班级群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量获取班级群会话信息",
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
Examples: []string{
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchCreateClassGroupCmd := &cobra.Command{
Use: "create-student-groups",
Short: "批量创建师生群",
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
Example: ` dws edu-group batch create-student-groups
dws edu-group batch create-student-groups -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
"input": map[string]any{},
})
},
}
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_create_class_group",
CanonicalPath: "edu-group.batch_create_class_group",
CLIPath: "edu-group batch create-student-groups",
PrimaryCLIPath: "edu-group batch create-student-groups",
},
Description: "批量创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为组织下符合条件的班级批量创建师生群",
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
AvoidWhen: []string{"单个班级创建用 student-group create"},
Examples: []string{
"dws edu-group batch create-student-groups --format json",
},
},
Parameters: []contract.ParamDecl{},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// student-group flags
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
// class-group flags
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
// batch flags
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
studentGroupCmd.AddCommand(
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
studentGroupIsInCmd, studentGroupConversationCmd,
studentGroupCreateCmd, studentGroupDisbandCmd,
)
classGroupCmd.AddCommand(
classGroupConversationIDCmd, classGroupConversationCmd,
classGroupExistsCmd, classGroupListByConversationIDsCmd,
)
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
return root
}
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
// if the flag is empty or not a valid integer.
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
func eduGroupParseCSV(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
result = append(result, v)
}
}
return result
}
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
func eduGroupParseIntCSV(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
return result, nil
}
-366
View File
@@ -1,366 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// withEduGroupCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"student-group", "info", "--dept-id", "123"},
{"student-group", "exists", "--dept-id", "123"},
{"student-group", "members", "--dept-id", "123"},
{"student-group", "is-in", "--dept-id", "123"},
{"student-group", "conversation", "--dept-id", "123"},
{"student-group", "create", "--dept-id", "123"},
{"student-group", "disband", "--dept-id", "123"},
{"class-group", "conversation-id", "--dept-id", "123"},
{"class-group", "conversation", "--dept-id", "123"},
{"class-group", "exists", "--dept-id", "123"},
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
{"batch", "check-student-group", "--class-ids", "1,2"},
{"batch", "get-class-groups", "--class-ids", "1,2"},
{"batch", "create-student-groups"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
// Each dept-id command must reject an absent --dept-id, covering its own
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
deptIDCommands := [][]string{
{"student-group", "info"},
{"student-group", "exists"},
{"student-group", "members"},
{"student-group", "is-in"},
{"student-group", "conversation"},
{"student-group", "create"},
{"student-group", "disband"},
{"class-group", "conversation-id"},
{"class-group", "conversation"},
{"class-group", "exists"},
}
for _, args := range deptIDCommands {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
t.Run("non-integer dept-id", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("non-integer dept-id error = %v", err)
}
})
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("missing conversation-ids error = %v", err)
}
})
t.Run("list-by-cids only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("empty conversation-ids error = %v", err)
}
})
t.Run("batch check missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("invalid integer class-ids error = %v", err)
}
})
t.Run("batch get missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch get only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("empty class-ids error = %v", err)
}
})
}
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduGroupParseCSV = %#v", got)
}
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
}
}
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
// exercise the full dispatch path through deps.Caller.CallTool.
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "get_class_group_info" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(123) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
}
})
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "create_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(456) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
}
})
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "disband_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(789) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
}
})
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "list_groups_by_conversation_ids" {
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
cids, ok := input["conversationIds"].([]string)
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
}
})
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "batch_check_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
classIDs, ok := input["classIds"].([]int64)
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
}
})
}
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduGroupConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduGroupCommand())
return root
}
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"student-group disband",
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
"disband_class_group",
map[string]any{"deptId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-group" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+2 -145
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
@@ -136,13 +134,6 @@ func WrapErrorWithOperation(err error, operation string) error {
if _, ok := err.(*PATError); ok {
return err
}
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
if apperrors.IsConfirmationRequired(err) {
return err
}
msg := err.Error()
// File lock timeout (cross-process lock)
@@ -263,24 +254,6 @@ func WrapErrorWithOperation(err error, operation string) error {
}
}
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
// 透传服务端原始错误信息。
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
errMsg := msg
var body map[string]any
if json.Unmarshal([]byte(msg), &body) == nil {
errMsg = businessErrorDisplayMessage(body, msg)
}
return &CLIError{
Code: CodeMCPToolError,
Message: errMsg,
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
Operation: operation,
Cause: err,
}
}
// Resource not found
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
suggestion := "Check if the resource exists or if your account has permission"
@@ -406,98 +379,21 @@ func suggestForBusinessErrorText(text string) string {
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
strings.Contains(text, "成员不存在"):
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
strings.Contains(text, "FORBIDDEN"):
return permissionDeniedSuggestion(text)
default:
return ""
}
}
// documentPermissionServerCodes are permission-denied codes that are
// demonstrably drive-specific (their identity carries the forbidden.* domain
// prefix). Only these (plus the document/wiki role-threshold message wording)
// justify the dws drive permission apply-* guidance. Generic code names such
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
// (get-self-setting) and event-subscription tools have been observed returning
// NO_PERMISSION, so keying guidance on it would mislead non-document products
// exactly the way this classification tries to avoid.
var documentPermissionServerCodes = map[string]bool{
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// documentPermissionErrorText reports whether a permission-denied message is
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
// threshold wording emitted by the member-management APIs or the document
// permission error codes embedded in the message text.
func documentPermissionErrorText(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
return strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
}
// isDocumentPermissionError reports whether a permission-denied body is
// specific to document/wiki access, where applying for access via
// dws drive permission apply-* is the actionable next step.
func isDocumentPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
return true
}
}
return documentPermissionErrorText(businessErrorMessage(body))
}
// genericPermissionSuggestion is the product-neutral suggestion for
// permission-denied errors that are not specific to document/wiki access.
const genericPermissionSuggestion = "Verify your account has permission for this resource"
// permissionDeniedSuggestion picks between document apply-permission guidance
// and a product-neutral hint: only document/wiki-specific errors point at
// dws drive permission apply-*, so other products never get a misleading
// document-permission suggestion.
func permissionDeniedSuggestion(text string) string {
if documentPermissionErrorText(text) {
return permissionApplyGuidance
}
return genericPermissionSuggestion
}
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
// body: document/wiki-specific errors get the apply-permission guidance; other
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
// or fall back to the product-neutral hint.
func permissionDeniedSuggestionFor(body map[string]any) string {
if isDocumentPermissionError(body) {
return permissionApplyGuidance
}
if suggestion := suggestForBusinessError(body); suggestion != "" {
return suggestion
}
return genericPermissionSuggestion
}
// ClassifyToolResultContent checks a raw MCP tool result content map for
// DWS gateway auth errors and PAT permission error codes. This is used as the
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
// a typed error before its generic business-error classification.
//
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
// no-permission guidance.
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
func ClassifyToolResultContent(content map[string]any) error {
if _, ok := getDWSGatewayErrorCode(content); ok {
raw, _ := json.Marshal(content)
@@ -512,20 +408,6 @@ func ClassifyToolResultContent(content map[string]any) error {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
// Permission-denied business errors: surface apply-permission guidance before
// the framework's generic rendering swallows it. Guidance is limited to
// document/wiki-specific signals (forbidden.* codes or role-threshold
// wording); other products keep the product-neutral suggestion.
if isNoPermissionError(content) {
suggestion := permissionDeniedSuggestionFor(content)
raw, _ := json.Marshal(content)
return &CLIError{
Code: CodeAuthPermission,
Message: businessErrorDisplayMessage(content, string(raw)),
Suggestion: suggestion,
}
}
return nil
}
@@ -537,23 +419,6 @@ var patNoPermissionCodes = map[string]bool{
"PAT_HIGH_RISK_NO_PERMISSION": true,
}
// noPermissionServerCodes are business-level permission-denied codes (from the
// MCP server / gateway) that should surface apply-permission guidance instead of
// being swallowed by the framework's generic business-error rendering.
var noPermissionServerCodes = map[string]bool{
"NO_PERMISSION": true,
"FORBIDDEN": true,
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// permissionApplySteps lists the two-step commands to apply for document access.
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
// Returns a typed error for known gateway auth failures, PAT interceptions,
// and business-level errors embedded in HTTP-200 JSON bodies.
@@ -587,18 +452,10 @@ func ClassifyMCPResponseText(text string) error {
}
}
if isNoPermissionError(body) {
return &CLIError{
Code: CodeAuthPermission,
Message: text,
Suggestion: permissionDeniedSuggestionFor(body),
}
}
if isBusinessError(body) {
return &CLIError{
Code: CodeMCPToolError,
Message: businessErrorDisplayMessage(body, text),
Message: text,
Suggestion: suggestForBusinessError(body),
}
}
+8 -164
View File
@@ -123,19 +123,10 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
"User has no permission to access this email": "mailbox list",
"频率超限": "rate limit",
"rate limit": "rate limit",
"用户不存在": "--members",
"不属于当前组织": "--members",
"成员不存在": "--members",
"未找到指定工具": "未注册",
"MCP不存在": "未注册",
"参数错误": "input parameters",
"param error": "input parameters",
// 非文档类权限文本不再给出 drive permission apply 指引。
"无权限访问": "Verify your account",
"权限不足": "Verify your account",
// 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。
"您没有权限访问该文档 (forbidden.no.auth)": "权限申请",
"无权限访问:需要您具备 MANAGER 及以上角色": "权限申请",
}
for input, want := range suggestions {
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
@@ -161,76 +152,17 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
t.Errorf("PAT key %s classified as %#v", key, err)
}
}
// forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED
// 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。
for _, key := range []string{"code", "errorCode", "server_error_code"} {
err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err)
continue
}
if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion)
}
}
// 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。
for _, code := range []string{"forbidden.no.auth"} {
err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"})
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err)
}
}
// 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试):
// 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。
nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"})
if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc)
}
// NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。
docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo)
}
// 非文档产品的权限错误保留产品专属建议(邮件)或通用提示,
// 不得被改写为 drive 文档权限申请指引。
mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"})
if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail)
}
generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"})
if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic)
}
for _, tc := range []struct {
text string
kind string
suggHas string
text string
kind string
}{
{"not-json", "nil", ""},
{"null", "nil", ""},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""},
{`{"error":"Missing service_id or access_key"}`, "cli", ""},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""},
{`{"success":false,"errorMsg":"rate limit"}`, "cli", ""},
{`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"},
// 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持
// 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。
{`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"},
{`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"},
// 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。
{`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"},
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
{`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""},
{`{"success":true}`, "nil", ""},
{"not-json", "nil"},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
{`{"error":"Missing service_id or access_key"}`, "cli"},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
{`{"success":true}`, "nil"},
} {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
@@ -242,15 +174,6 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
if _, ok := err.(*CLIError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
}
case "authperm":
cli, ok := err.(*CLIError)
if !ok || cli.Code != CodeAuthPermission {
t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err)
continue
}
if !strings.Contains(cli.Suggestion, tc.suggHas) {
t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas)
}
case "pat":
if _, ok := err.(*PATError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
@@ -312,82 +235,3 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
t.Fatalf("stripClassFields slice = %s", got)
}
}
// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577:
// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。
func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) {
if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" {
t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first")
}
want := "members[0].id 对应的用户不存在"
if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want {
t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want)
}
if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" {
t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second")
}
if msg := businessErrorMessage(map[string]any{}); msg != "" {
t.Errorf("businessErrorMessage(empty) = %q, want empty", msg)
}
}
// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。
func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) {
got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw")
if got != "权限不足 (logId: abc123)" {
t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got)
}
// errorCode/code 存在时附加在后,保证后端错误码可见
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw")
if got != "失败 (code: forbidden.document.sizeOverLimit)" {
t.Errorf("businessErrorDisplayMessage = %q, want code appended", got)
}
got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw")
if got != "失败 (code: forbidden.accessDenied, logId: l1)" {
t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got)
}
// logId 已包含在 message 中时不重复追加
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw")
if got != "失败 logId:abc123" {
t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got)
}
// 无任何 message 字段时回退 rawText
if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" {
t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got)
}
}
// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」
// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。
func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) {
suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"})
if !strings.Contains(suggestion, "--members") {
t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion)
}
for _, msg := range []string{
"用户不存在",
"不属于当前组织",
"成员不存在",
} {
cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError)
if !ok {
t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg)
}
if cli.Code != CodeMCPToolError {
t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError)
}
if !strings.Contains(cli.Suggestion, "corpId") {
t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion)
}
}
// JSON 体形式:透传 errorMessage 且附 logId
cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError)
if !ok {
t.Fatal("WrapErrorWithOperation(json) not a CLIError")
}
if cli.Message != "用户不存在 (logId: lid-1)" {
t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message)
}
}
+10 -89
View File
@@ -255,11 +255,7 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
}
if isBusinessError(errBody) {
return "", &CLIError{
Code: CodeMCPToolError,
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
// (如 drive list --depth 的限流重试)的输入,下游会从
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
Code: CodeMCPToolError,
Message: c.Text,
Suggestion: suggestForBusinessError(errBody),
}
@@ -444,20 +440,6 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
// 尝试将返回文本解析为 JSON,进行错误分类
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
// errBody == nil 表示文本为 "null",服务端返回了空响应。
// 仅对已确认“空响应=写成功”契约的 permission/member update/remove
// 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的
// 合法 null 保持原样输出,不改变未版本化的机器输出契约。
if errBody == nil {
if nullOnSuccessTools[toolName] {
if deps.Caller.Format() == "json" {
return printJSON(map[string]any{})
}
deps.Out.PrintRaw("{}")
return nil
}
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
}
// 网关层错误(如 token 过期)
if _, ok := getDWSGatewayErrorCode(errBody); ok {
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
@@ -472,7 +454,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
}
// 业务逻辑错误
if isBusinessError(errBody) {
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
}
}
@@ -483,18 +465,6 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
return printJSON(result)
}
// nullOnSuccessTools lists MCP tools whose server contract is confirmed to
// return a literal JSON null for successful no-payload writes (permission /
// member update/remove). Only these get the null→{} adaptation; every other
// tool keeps its raw null output so the shared machine-output contract stays
// unchanged.
var nullOnSuccessTools = map[string]bool{
"update_permission": true,
"remove_permission": true,
"update_member": true,
"remove_member": true,
}
// RenderLegacyMCPText renders an already-fetched MCP text response through the
// exact legacy formatter. It lets dual validation execute the business request
// once, validate a shadow unified result, and still preserve legacy bytes.
@@ -795,64 +765,15 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
return suggestForBusinessErrorText(businessErrorMessage(body))
}
// businessErrorMessage extracts the human-readable message from a parsed error
// body, checking errorMsg > errorMessage > message > error. Returns "" if none present.
func businessErrorMessage(body map[string]any) string {
for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} {
if v, ok := body[k].(string); ok && v != "" {
return v
}
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return ""
}
// businessErrorDisplayMessage extracts the human-readable message from a parsed
// error body, appends the backend error code and logId if present (for
// traceability), and falls back to rawText if no message field is found.
func businessErrorDisplayMessage(body map[string]any, rawText string) string {
msg := businessErrorMessage(body)
if msg == "" {
return rawText
}
var extras []string
for _, k := range []string{"errorCode", "error_code", "code"} {
if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) {
extras = append(extras, "code: "+code)
break
}
}
if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) {
extras = append(extras, "logId: "+logId)
}
if len(extras) > 0 {
msg = msg + " (" + strings.Join(extras, ", ") + ")"
}
return msg
}
// isNoPermissionError reports whether a parsed error body represents a
// permission-denied error, by known server codes or message text. Used to
// surface apply-permission guidance before the framework's generic rendering.
func isNoPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && noPermissionServerCodes[code] {
return true
}
}
msg := businessErrorMessage(body)
if msg == "" {
return false
}
lower := strings.ToLower(msg)
return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") ||
strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") ||
strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") ||
strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色")
return suggestForBusinessErrorText(msg)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+3 -3
View File
@@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) {
`{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
} {
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
_ = runDocReadJsonML("node", "", "", 0, false)
_ = runDocReadJsonML(nil, "node", "")
}
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false)
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true)
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
}
-738
View File
@@ -1,738 +0,0 @@
package helpers
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
)
// 同步自闭源 MR 28965577(知识库/节点权限增删改查改造):
// 权限/成员 add/update/remove 支持 --members 新格式(USER/DEPT/CONVERSATION/TAG),
// list 支持 nextToken 翻页。以下用例覆盖 CLI → MCP 参数装配契约。
func TestCollectMembersParsesNewFormat(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--members", `[{"type":"USER","id":"u1","roleId":"reader","corpId":"c1"},{"type":"TAG","id":"t1","roleId":"editor","corpId":"c1"}]`,
"--notify=false"); err != nil {
t.Fatalf("permission add --members: %v", err)
}
if caller.tool != "add_permission" {
t.Fatalf("tool=%q", caller.tool)
}
members, ok := caller.args["members"].([]map[string]any)
if !ok || len(members) != 2 {
t.Fatalf("members=%#v", caller.args["members"])
}
if members[0]["roleId"] != "READER" || members[1]["roleId"] != "EDITOR" {
t.Fatalf("roleId normalize failed: %#v", members)
}
if notify, ok := caller.args["notify"].(bool); !ok || notify {
t.Fatalf("notify should be false when --notify=false: %#v", caller.args["notify"])
}
}
func TestCollectMembersValidation(t *testing.T) {
cases := []struct {
name string
members string
wantError string
}{
{"invalid json", "[{", "JSON 解析失败"},
{"empty array", "[]", "不能为空数组"},
{"missing type", `[{"id":"u1","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 type"},
{"missing id", `[{"type":"USER","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 id"},
{"missing corpId", `[{"type":"USER","id":"u1","roleId":"READER"}]`, "需携带 corpId"},
{"missing roleId", `[{"type":"USER","id":"u1","corpId":"c1"}]`, "缺少必填字段 roleId"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", "--members", tc.members)
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("expected error containing %q, got %v", tc.wantError, err)
}
})
}
}
func TestValidateMembersExclusivity(t *testing.T) {
t.Run("members and users are mutually exclusive", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--users", "u1", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "互斥") {
t.Fatalf("expected mutual exclusion error, got %v", err)
}
})
t.Run("one of members or users is required", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1")
if err == nil || !strings.Contains(err.Error(), "之一") {
t.Fatalf("expected required error, got %v", err)
}
})
t.Run("role is redundant with members", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
"--role", "READER", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "不需要 --role") {
t.Fatalf("expected no-role error, got %v", err)
}
})
}
func TestPermissionListPagination(t *testing.T) {
t.Run("drive permission list passes nextToken and pageSize", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1",
"--limit", "50", "--next-token", "50"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.tool != "list_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != 50 {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
if caller.args["nextToken"] != "50" {
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
}
})
t.Run("doc permission list passes nextToken", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "permission", "list", "--node", "n1",
"--next-token", "30"); err != nil {
t.Fatalf("doc permission list: %v", err)
}
if caller.args["nextToken"] != "30" {
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
}
})
t.Run("wiki member list passes nextToken and pageSize", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newWikiCommand(), "member", "list", "--workspace", "ws1",
"--limit", "50", "--next-token", "50"); err != nil {
t.Fatalf("wiki member list: %v", err)
}
if caller.tool != "list_member" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != 50 || caller.args["nextToken"] != "50" {
t.Fatalf("args=%#v", caller.args)
}
})
}
func TestPermissionRemoveWithMembers(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
// remove 是 destructive 入口:Safety confirmation=user_required,测试里
// 注入根级 --yes 后直接确认执行(参数装配本身由下方断言验证)。
root := newDriveCommand()
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
if err := executePR868Command(t, root, "permission", "remove", "--node", "n1",
"--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes"); err != nil {
t.Fatalf("permission remove --members: %v", err)
}
if caller.tool != "remove_permission" {
t.Fatalf("tool=%q", caller.tool)
}
members, ok := caller.args["members"].([]map[string]any)
if !ok || len(members) != 1 || members[0]["id"] != "cid1" {
t.Fatalf("members=%#v", caller.args["members"])
}
// remove 语义下 roleId 不应被要求
if _, hasRole := members[0]["roleId"]; hasRole {
t.Fatalf("remove members should not require roleId: %#v", members[0])
}
}
func TestWikiMemberAddWithMembersRejectsOwner(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newWikiCommand(), "member", "add", "--workspace", "ws1",
"--members", `[{"type":"USER","id":"u1","roleId":"OWNER","corpId":"c1"}]`)
if err == nil || !strings.Contains(err.Error(), "OWNER") {
t.Fatalf("expected OWNER rejection, got %v", err)
}
}
// ──────────────────────────────────────────────────────
// collectMembers / validateMembersExclusivity 单元级用例
// 同步自内部 MR 28965577 补齐的覆盖:四类型混传、remove 语义、
// 超过 30 个、corpId 按类型校验、--user 别名。
// ──────────────────────────────────────────────────────
// newMembersCmd 构造带 --members/--users/--user/--role 的最小命令,
// 与生产命令的 flag 注册保持一致。
func newMembersCmd() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("members", "", "")
cmd.Flags().String("users", "", "")
cmd.Flags().String("user", "", "")
cmd.Flags().String("role", "", "")
return cmd
}
func TestCollectMembersMixedTypes(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"MANAGER","corpId":"c1"},{"type":"DEPT","id":"d1","roleId":"editor","corpId":"c1"},{"type":"CONVERSATION","id":"cid1","roleId":"READER"},{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 4 {
t.Fatalf("expected 4 members, got %d", len(got))
}
if got[1]["roleId"] != "EDITOR" {
t.Errorf("member[1] roleId should be normalized to EDITOR, got %v", got[1]["roleId"])
}
if got[0]["corpId"] != "c1" {
t.Errorf("corpId should be preserved, got %v", got[0]["corpId"])
}
}
func TestCollectMembersRemoveSemantics(t *testing.T) {
t.Run("remove only needs type and id", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"},{"type":"DEPT","id":"d1","corpId":"c1"}]`)
got, err := collectMembers(cmd, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 members, got %d", len(got))
}
})
t.Run("add still requires roleId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for missing roleId, got nil")
}
})
}
func TestCollectMembersOver30(t *testing.T) {
cmd := newMembersCmd()
members := "["
for i := 0; i < 31; i++ {
if i > 0 {
members += ","
}
members += fmt.Sprintf(`{"type":"USER","id":"u%d","roleId":"READER","corpId":"c"}`, i)
}
members += "]"
_ = cmd.Flags().Set("members", members)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for >30 members, got nil")
}
}
func TestCollectMembersCorpIDByType(t *testing.T) {
t.Run("DEPT requires corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"DEPT","id":"d1","roleId":"EDITOR"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for DEPT missing corpId, got nil")
}
})
t.Run("TAG requires corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER"}]`)
if _, err := collectMembers(cmd, false); err == nil {
t.Fatal("expected error for TAG missing corpId, got nil")
}
})
t.Run("CONVERSATION works without corpId", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"CONVERSATION","id":"cid1","roleId":"READER"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error for CONVERSATION without corpId: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 member, got %d", len(got))
}
})
t.Run("TAG with corpId passes", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
got, err := collectMembers(cmd, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 member, got %d", len(got))
}
})
}
func TestValidateMembersExclusivityUserAlias(t *testing.T) {
t.Run("--user alias counts as users", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("user", "u1")
if err := validateMembersExclusivity(cmd); err != nil {
t.Errorf("unexpected error for --user alias: %v", err)
}
})
t.Run("--user alias conflicts with --members", func(t *testing.T) {
cmd := newMembersCmd()
_ = cmd.Flags().Set("user", "u1")
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
if err := validateMembersExclusivity(cmd); err == nil {
t.Error("expected error when both --user and --members set, got nil")
}
})
}
// ──────────────────────────────────────────────────────
// isNoPermissionError — 新接口 forbidden.accessDenied 错误码
// ──────────────────────────────────────────────────────
func TestIsNoPermissionErrorAccessDenied(t *testing.T) {
t.Run("server code forbidden.accessDenied", func(t *testing.T) {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
body := map[string]any{key: "forbidden.accessDenied"}
if !isNoPermissionError(body) {
t.Errorf("isNoPermissionError(%s=forbidden.accessDenied) = false, want true", key)
}
}
})
t.Run("access-denied message text", func(t *testing.T) {
for _, msg := range []string{
"需要您具备 EDITOR 及以上角色",
"需要您具备 MANAGER 及以上角色才能执行此操作",
"forbidden.accessDenied",
"Forbidden.AccessDenied",
} {
body := map[string]any{"errorMsg": msg}
if !isNoPermissionError(body) {
t.Errorf("isNoPermissionError(msg=%q) = false, want true", msg)
}
}
})
t.Run("normal body is not permission error", func(t *testing.T) {
body := map[string]any{"success": true, "errorMsg": ""}
if isNoPermissionError(body) {
t.Error("isNoPermissionError(success body) = true, want false")
}
})
}
// ──────────────────────────────────────────────────────
// --notify bool flag 解析(NoArgs 防御 `--notify false` 空格形式)
// ──────────────────────────────────────────────────────
// newNotifyCmd 构造与生产 add/update 命令一致的最小命令:
// --notify bool flag + Args: cobra.NoArgs。
func newNotifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmd.Flags().Bool("notify", false, "")
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func TestNotifyFlagParsing(t *testing.T) {
t.Run("equals form sets false", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{"--notify=false"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, _ := cmd.Flags().GetBool("notify"); got {
t.Error("--notify=false: got true, want false")
}
if !cmd.Flags().Changed("notify") {
t.Error("--notify=false should mark flag as changed")
}
})
t.Run("bare flag defaults true via NoOptDefVal", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{"--notify"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, _ := cmd.Flags().GetBool("notify"); !got {
t.Error("--notify (no value): got false, want true (NoOptDefVal)")
}
if !cmd.Flags().Changed("notify") {
t.Error("--notify should mark flag as changed")
}
})
t.Run("space form rejected by NoArgs", func(t *testing.T) {
cmd := newNotifyCmd()
// "--notify false" 被 pflag 解析为 notify=true(NoOptDefVal)+ 位置参数 "false"。
// Args: cobra.NoArgs 拒绝位置参数,防止用户误以为传了 false。
cmd.SetArgs([]string{"--notify", "false"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for '--notify false' (space form) with NoArgs, got nil")
}
if got, _ := cmd.Flags().GetBool("notify"); !got {
t.Error("pflag should set notify=true via NoOptDefVal for bare --notify, got false")
}
})
t.Run("omitted stays unchanged", func(t *testing.T) {
cmd := newNotifyCmd()
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cmd.Flags().Changed("notify") {
t.Error("notify should not be marked changed when omitted")
}
if got, _ := cmd.Flags().GetBool("notify"); got {
t.Error("omitted notify: got true, want false (default)")
}
})
}
// ──────────────────────────────────────────────────────
// pageSize 1..50 运行时边界(P2 意见:服务端 pageSize 上限 50)
// ──────────────────────────────────────────────────────
func newPageSizeCmd(changed string, value int) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("limit", 30, "")
cmd.Flags().Int("max-results", 0, "")
_ = cmd.Flags().Set(changed, fmt.Sprintf("%d", value))
return cmd
}
func TestPermissionPageSizeFromFlagsBoundaries(t *testing.T) {
t.Run("rejects out-of-range values", func(t *testing.T) {
for _, flag := range []string{"limit", "max-results"} {
for _, size := range []int{-1, 0, 51, 100} {
if _, _, err := permissionPageSizeFromFlags(newPageSizeCmd(flag, size)); err == nil {
t.Errorf("--%s %d: expected error, got nil", flag, size)
}
}
}
})
t.Run("accepts in-range values", func(t *testing.T) {
for _, size := range []int{1, 30, 50} {
got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", size))
if err != nil {
t.Errorf("--limit %d: unexpected error %v", size, err)
continue
}
if !ok || got != size {
t.Errorf("--limit %d: got (%d, %v)", size, got, ok)
}
}
})
t.Run("omitted returns ok=false", func(t *testing.T) {
// newPageSizeCmd 强制 Set(changed),先验证 Set 后 Changed 生效,
// 再用未设置任何 flag 的命令验证默认路径。
if got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", 30)); err != nil || !ok || got != 30 {
t.Fatalf("setup sanity failed: got (%d, %v, %v), want (30, true, nil)", got, ok, err)
}
fresh := &cobra.Command{Use: "test"}
fresh.Flags().Int("limit", 30, "")
fresh.Flags().Int("max-results", 0, "")
if got, ok, err := permissionPageSizeFromFlags(fresh); err != nil || ok || got != 0 {
t.Errorf("omitted flags: got (%d, %v, %v), want (0, false, nil)", got, ok, err)
}
})
}
func TestPermissionListPageSizeRuntimeGuard(t *testing.T) {
type listCase struct {
name string
root func() *cobra.Command
path []string
nodeID []string
}
cases := []listCase{
{"drive", newDriveCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
{"doc", newDocCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
{"wiki", newWikiCommand, []string{"member", "list"}, []string{"--workspace", "ws1"}},
}
for _, lc := range cases {
t.Run(lc.name+" rejects --limit 51", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.nodeID...)
args = append(args, "--limit", "51")
err := executePR868Command(t, lc.root(), args...)
if err == nil || !strings.Contains(err.Error(), "1..50") {
t.Fatalf("expected 1..50 validation error, got %v", err)
}
if caller.calls != 0 {
t.Fatalf("list should not call MCP with invalid pageSize, called %d times", caller.calls)
}
})
t.Run(lc.name+" accepts --limit 50", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.nodeID...)
args = append(args, "--limit", "50")
if err := executePR868Command(t, lc.root(), args...); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if caller.args["pageSize"] != 50 {
t.Fatalf("pageSize=%#v, want 50", caller.args["pageSize"])
}
})
}
}
// ──────────────────────────────────────────────────────
// add 的 --notify 声明默认值必须与实际透传行为一致
//
// 三个 add 命令都用 `if Flags().Changed("notify")` 守卫来决定是否把 notify
// 写入 toolArgs,所以省略该 flag 时字段根本不会发给服务端,实测服务端按
// 不通知处理。声明默认值因此必须是 false —— 若有人把它改回 true,help 会
// 承诺一个从未透传的默认值(曾经的真实缺陷)。
// ──────────────────────────────────────────────────────
func TestPermissionAddNotifyDefaultMatchesWireBehavior(t *testing.T) {
type addCase struct {
name string
root func() *cobra.Command
path []string
target []string
}
cases := []addCase{
{"drive", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
{"doc", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
{"wiki", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}},
}
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
runAdd := func(t *testing.T, lc addCase, extra ...string) *scriptedToolCaller {
t.Helper()
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, lc.path...), lc.target...)
args = append(args, "--members", members)
args = append(args, extra...)
if err := executePR868Command(t, lc.root(), args...); err != nil {
t.Fatalf("unexpected error: %v", err)
}
return caller
}
for _, lc := range cases {
t.Run(lc.name+" omitted sends no notify field", func(t *testing.T) {
caller := runAdd(t, lc)
if v, present := caller.args["notify"]; present {
t.Fatalf("notify must be absent from toolArgs when --notify is omitted, got %#v", v)
}
})
t.Run(lc.name+" declared default is false", func(t *testing.T) {
// 声明默认值必须与上面的透传行为一致,否则 help 在说谎。
root := lc.root()
leaf, _, err := root.Find(lc.path)
if err != nil {
t.Fatalf("find %v: %v", lc.path, err)
}
flag := leaf.Flags().Lookup("notify")
if flag == nil {
t.Fatal("notify flag not declared")
}
if flag.DefValue != "false" {
t.Errorf("notify DefValue=%q, want \"false\": 省略时不透传该字段,声明 true 会承诺一个未生效的默认值", flag.DefValue)
}
})
t.Run(lc.name+" bare --notify opts in", func(t *testing.T) {
caller := runAdd(t, lc, "--notify")
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
}
})
t.Run(lc.name+" --notify=false is sent explicitly", func(t *testing.T) {
caller := runAdd(t, lc, "--notify=false")
notify, ok := caller.args["notify"].(bool)
if !ok || notify {
t.Fatalf("notify=%#v, want explicit false", caller.args["notify"])
}
})
}
}
// ──────────────────────────────────────────────────────
// 服务端对已确认“空响应=写成功”契约的 permission/member update/remove
// 工具可能返回字面 "null"(操作成功但无返回数据)。CLI 仅对这几个
// 工具将其渲染为空对象 {},避免 Agent/下游把 null 当作对象解析时失败;
// 其它工具的合法 null 保持原样输出,公共渲染器的机器输出契约不变。
// ──────────────────────────────────────────────────────
func TestCrossPlatformCoverageNullToolResponseRendersEmptyObject(t *testing.T) {
// nullOnSuccessTools 集合内:null 适配为 {}
for _, format := range []string{"json", "raw"} {
t.Run("adapted_"+format, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
installScriptedCaller(t, caller)
out := &bytes.Buffer{}
deps.Out.w = out
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_permission", nil, false); err != nil {
t.Fatalf("null tool response should render as {}: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "{}" {
t.Errorf("format %s renders %q, want {}", format, got)
}
})
}
// 集合外的工具:null 原样输出,不再被公共渲染路径改写为 {}
for _, format := range []string{"json", "raw"} {
t.Run("passthrough_"+format, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
installScriptedCaller(t, caller)
out := &bytes.Buffer{}
deps.Out.w = out
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_node_permission", nil, false); err != nil {
t.Fatalf("null passthrough should not error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "null" {
t.Errorf("format %s renders %q, want null unchanged", format, got)
}
})
}
}
// ──────────────────────────────────────────────────────
// update / remove 的 --members 装配与 --users 解析失败分支
//
// add 的 members+notify 透传已有
// TestPermissionAddNotifyDefaultMatchesWireBehavior 覆盖;这里补齐
// update(members + notify)与 doc/wiki remove(members)的同构分支,
// 以及各产品 add/update/remove 中 collectUserIDs 的错误路径:
// --users 传纯空白时 flagOrFallback 视为已提供(非空字符串),
// 但 parseCommentMentionIds 过滤空白后为空,collectUserIDs 必须报错
// 而不是向服务端发送空 userIds。
// ──────────────────────────────────────────────────────
func TestCrossPlatformCoveragePermissionUpdateWithMembers(t *testing.T) {
type updateCase struct {
name string
root func() *cobra.Command
path []string
target []string
tool string
}
cases := []updateCase{
{"drive", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
{"doc", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
{"wiki", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, "update_member"},
}
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
for _, uc := range cases {
t.Run(uc.name+" passes members and notify", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
args := append(append([]string{}, uc.path...), uc.target...)
args = append(args, "--members", members, "--notify")
if err := executePR868Command(t, uc.root(), args...); err != nil {
t.Fatalf("update --members: %v", err)
}
if caller.tool != uc.tool {
t.Fatalf("tool=%q, want %q", caller.tool, uc.tool)
}
got, ok := caller.args["members"].([]map[string]any)
if !ok || len(got) != 1 || got[0]["id"] != "u1" || got[0]["roleId"] != "READER" {
t.Fatalf("members=%#v", caller.args["members"])
}
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
}
})
}
}
func TestCrossPlatformCoveragePermissionRemoveWithMembersProducts(t *testing.T) {
// drive remove 已由 TestPermissionRemoveWithMembers 覆盖;这里补 doc/wiki。
type removeCase struct {
name string
root func() *cobra.Command
path []string
target []string
tool string
}
cases := []removeCase{
{"doc", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, "remove_permission"},
{"wiki", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, "remove_member"},
}
for _, rc := range cases {
t.Run(rc.name+" passes members", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
// remove 需用户确认(confirmation=user_required):注入根级 --yes。
root := rc.root()
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
args := append(append([]string{}, rc.path...), rc.target...)
args = append(args, "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes")
if err := executePR868Command(t, root, args...); err != nil {
t.Fatalf("remove --members: %v", err)
}
if caller.tool != rc.tool {
t.Fatalf("tool=%q, want %q", caller.tool, rc.tool)
}
got, ok := caller.args["members"].([]map[string]any)
if !ok || len(got) != 1 || got[0]["id"] != "cid1" {
t.Fatalf("members=%#v", caller.args["members"])
}
})
}
}
func TestCrossPlatformCoveragePermissionUsersBlankParseError(t *testing.T) {
type blankCase struct {
name string
root func() *cobra.Command
path []string
target []string
needRole bool
}
cases := []blankCase{
{"drive add", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
{"drive update", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
{"drive remove", newDriveCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
{"doc add", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
{"doc update", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
{"doc remove", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
{"wiki add", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}, true},
{"wiki update", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, true},
{"wiki remove", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, false},
}
for _, bc := range cases {
t.Run(bc.name+" rejects blank --users", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, bc.path...), bc.target...)
args = append(args, "--users", " ")
if bc.needRole {
// add/update 的旧格式分支在 collectUserIDs 之前校验必填 --role。
args = append(args, "--role", "READER")
}
err := executePR868Command(t, bc.root(), args...)
if err == nil || !strings.Contains(err.Error(), "--users is required") {
t.Fatalf("expected --users is required error, got %v", err)
}
if caller.calls != 0 {
t.Fatalf("MCP must not be called when --users resolves empty, called %d times", caller.calls)
}
})
}
}
@@ -1,71 +0,0 @@
package helpers
import (
"testing"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// P2 评审要求(PR #1085):三个游标列表命令(drive/doc permission list、
// wiki member list)必须声明 Contract.Pagination(cursor 分页,游标 flag 为
// next-token),且 next-token 是该叶的 declared parameter。
//
// wire 上的 pagination 投影由 output rollout 门控制(schema_runtime_registry
// 对未迁移统一结果 envelope 的命令不发布 result/pagination,避免 Schema 与
// runtime 输出不一致)。本测试固化「声明已就绪」与「当前仍为 legacy 输出」
// 两个事实;命令迁移 unified envelope 后 wire 投影自动生效,届时应更新此
// 断言并用 dws schema 验证最终投影。
// 命名携带 TestCrossPlatformCoverage 前缀:本测试覆盖本次改动的
// Contract.Pagination 声明行,需在 macOS/Windows 平台覆盖率门禁上执行。
func TestCrossPlatformCoveragePermissionListPaginationDeclaration(t *testing.T) {
cases := []struct {
name string
root *cobra.Command
path []string
}{
{"drive permission list", newDriveCommand(), []string{"permission", "list"}},
{"doc permission list", newDocCommand(), []string{"permission", "list"}},
{"wiki member list", newWikiCommand(), []string{"member", "list"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd, _, err := tc.root.Find(tc.path)
if err != nil || cmd == nil || cmd.Name() != tc.path[len(tc.path)-1] {
t.Fatalf("find leaf %v: cmd=%v err=%v", tc.path, cmd, err)
}
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok {
t.Fatalf("missing RuntimeContractFinal on %v", tc.path)
}
pg := final.Pagination
if pg == nil {
t.Fatalf("Contract.Pagination not declared on %v", tc.path)
}
if pg.Kind != contract.PaginationKindCursor || pg.CursorParameter != "next-token" {
t.Fatalf("pagination = %+v, want cursor/next-token", pg)
}
if pg.MetaPath != contract.PaginationMetaPath ||
pg.NextTokenPath != contract.PaginationNextTokenPath ||
pg.EndpointExhaustedPath != contract.PaginationExhaustedPath {
t.Fatalf("pagination framework-owned paths not normalized: %+v", pg)
}
declared := false
for _, p := range final.Parameters {
if p.Name == "next-token" {
declared = true
break
}
}
if !declared {
t.Fatalf("cursor flag next-token must be a declared parameter on %v", tc.path)
}
if output.ActiveContract(cmd) != output.ContractLegacy {
t.Fatalf("%v migrated to unified result; update this test and verify wire projection via dws schema", tc.path)
}
})
}
}
@@ -206,18 +206,8 @@ func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.args["pageSize"] != 10 {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
})
t.Run("permission list next-token pagination", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--next-token", "50"); err != nil {
t.Fatalf("permission list --next-token: %v", err)
}
if caller.args["nextToken"] != "50" {
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
if caller.args["maxResults"] != 10 {
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
}
})
t.Run("cover file-id alias", func(t *testing.T) {
-28
View File
@@ -1,28 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import "github.com/spf13/cobra"
// 家校/高校系列(家校通讯录、家校群、家校应用、家庭群、高校通讯录)是开源库
// 显式维护的公开命令,不依赖生成式产品注册表(register_products.go)。
// 其 MCP server 端点由运行时宿主按 serverID 解析。
func init() {
eduCommands := []struct {
name string
buildFn func() *cobra.Command
}{
{"edu-contact", newEduContactCommand},
{"edu-group", newEduGroupCommand},
{"edu-app", newEduAppCommand},
{"edu-familygroup", newEduFamilyGroupCommand},
{"college-contact", newCollegeContactCommand},
}
for _, c := range eduCommands {
c := c
RegisterPublic(func() Handler {
return wukongHandler{name: c.name, buildFn: c.buildFn}
})
}
}
@@ -1,280 +0,0 @@
package helpers
import (
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 156 条叶子命令(dry-run 模式),
// 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws <cmd> --dry-run。
func TestRunAllEduCommands(t *testing.T) {
type cmdCase struct {
product string
args []string
}
allCmds := []cmdCase{
// ═══════════════════════════════════════════════════════════
// edu-contact: 29 commands
// ═══════════════════════════════════════════════════════════
// school (6)
{"edu-contact", []string{"school", "roles"}},
{"edu-contact", []string{"school", "structure"}},
{"edu-contact", []string{"school", "periods"}},
{"edu-contact", []string{"school", "type"}},
{"edu-contact", []string{"school", "stats", "--statistics-type", "1"}},
{"edu-contact", []string{"school", "class-list"}},
// class (19)
{"edu-contact", []string{"class", "detail", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "students", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "same-name", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "user-role", "--dept-id", "12345"}},
{"edu-contact", []string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}},
{"edu-contact", []string{"class", "headmaster", "--class-name", "一年级1班"}},
{"edu-contact", []string{"class", "search-by-teacher", "--name", "张老师"}},
{"edu-contact", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}},
{"edu-contact", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--student-mobile", "13800138000"}},
{"edu-contact", []string{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "小明"}},
{"edu-contact", []string{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}},
{"edu-contact", []string{"class", "update-info", "--class-id", "123", "--nick", "火箭班"}},
{"edu-contact", []string{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S001"}},
{"edu-contact", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}},
{"edu-contact", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}},
{"edu-contact", []string{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"edu-contact", []string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}},
{"edu-contact", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}},
// family (2)
{"edu-contact", []string{"family", "children"}},
{"edu-contact", []string{"family", "parents"}},
// teacher (2)
{"edu-contact", []string{"teacher", "classes"}},
{"edu-contact", []string{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"MATH","courseName":"数学"}]`}},
// ═══════════════════════════════════════════════════════════
// edu-group: 14 commands
// ═══════════════════════════════════════════════════════════
// student-group (7)
{"edu-group", []string{"student-group", "info", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "exists", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "members", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "is-in", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "conversation", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "create", "--dept-id", "12345"}},
{"edu-group", []string{"student-group", "disband", "--dept-id", "12345"}},
// class-group (4)
{"edu-group", []string{"class-group", "conversation-id", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "conversation", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "exists", "--dept-id", "12345"}},
{"edu-group", []string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}},
// batch (3)
{"edu-group", []string{"batch", "check-student-group", "--class-ids", "12345,67890"}},
{"edu-group", []string{"batch", "get-class-groups", "--class-ids", "12345,67890"}},
{"edu-group", []string{"batch", "create-student-groups"}},
// ═══════════════════════════════════════════════════════════
// edu-app: 42 commands
// ═══════════════════════════════════════════════════════════
// message (1)
{"edu-app", []string{"message", "summary-list", "--class-id", "1", "--cid", "c1", "--target-role", "guardian", "--status", "0"}},
// task (3)
{"edu-app", []string{"task", "publish-list"}},
{"edu-app", []string{"task", "all-list", "--biz-id", "1"}},
{"edu-app", []string{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`}},
// report (5)
{"edu-app", []string{"report", "get", "--ids", "1001,1002"}},
{"edu-app", []string{"report", "by-teacher"}},
{"edu-app", []string{"report", "by-class", "--report-id", "1001", "--class-id", "12345"}},
{"edu-app", []string{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1"}},
{"edu-app", []string{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}},
// notice (7)
{"edu-app", []string{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1"}},
{"edu-app", []string{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假"}},
{"edu-app", []string{"notice", "delete", "--notice-id", "12345"}},
{"edu-app", []string{"notice", "list-by-teacher"}},
{"edu-app", []string{"notice", "get", "--notice-id", "12345"}},
{"edu-app", []string{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c1"}},
{"edu-app", []string{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
// circle (1)
{"edu-app", []string{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}},
// card (5)
{"edu-app", []string{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题"}},
{"edu-app", []string{"card", "end", "--card-id", "1"}},
{"edu-app", []string{"card", "list", "--status", "UNFINISH"}},
{"edu-app", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1"}},
{"edu-app", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1"}},
// diploma (9)
{"edu-app", []string{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "三好学生", "--user-name", "张三"}},
{"edu-app", []string{"diploma", "read", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "list-by-teacher"}},
{"edu-app", []string{"diploma", "get", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "statistics", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "detail", "--diploma-id", "1"}},
{"edu-app", []string{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"diploma", "delete", "--diploma-id", "1"}},
// homework (11)
{"edu-app", []string{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习册第3页"}},
{"edu-app", []string{"homework", "delete", "--homework-id", "1"}},
{"edu-app", []string{"homework", "submit", "--hw-content-detail-id", "1"}},
{"edu-app", []string{"homework", "get", "--homework-id", "1"}},
{"edu-app", []string{"homework", "class-by-homework", "--homework-id", "1"}},
{"edu-app", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c1", "--user-name", "张老师"}},
{"edu-app", []string{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c1"}},
{"edu-app", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c1", "--user-name", "张三"}},
{"edu-app", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c1"}},
{"edu-app", []string{"homework", "list-by-teacher"}},
{"edu-app", []string{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1"}},
// ═══════════════════════════════════════════════════════════
// edu-familygroup: 6 commands
// ═══════════════════════════════════════════════════════════
// group (2)
{"edu-familygroup", []string{"group", "check-exists", "--uid", "12345", "--group-name", "小明一家"}},
{"edu-familygroup", []string{"group", "list-children", "--uid", "12345"}},
// manage (4)
{"edu-familygroup", []string{"manage", "create", "--uid", "12345", "--children", `[{"name":"小明","students":[{"corpId":"corp1","staffId":"staff1"}]}]`}},
{"edu-familygroup", []string{"manage", "invite-parent", "--org-id", "12345", "--uid", "67890", "--mobile", "13800138000"}},
{"edu-familygroup", []string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", "--name", "小红", "--mobile", "13900139000"}},
{"edu-familygroup", []string{"manage", "toggle-app", "--org-id", "12345", "--uid", "67890", "--child-staff-id", "staff1", "--app-type", "XIAOTIANDI", "--open", "true"}},
// ═══════════════════════════════════════════════════════════
// college-contact: 65 commands
// ═══════════════════════════════════════════════════════════
// dept (9)
{"college-contact", []string{"dept", "get-standard-structure"}},
{"college-contact", []string{"dept", "get-detail", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "get-chain", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "search", "--dept-id", "12345", "--keyword", "计算机"}},
{"college-contact", []string{"dept", "create", "--super-id", "1", "--stru-dept-id", "2", "--name", "计算机学院", "--dept-type", "COLLEGE", "--create-dept-group", "true"}},
{"college-contact", []string{"dept", "update", "--dept-id", "12345", "--dept-type", "COLLEGE"}},
{"college-contact", []string{"dept", "delete", "--dept-id", "12345"}},
{"college-contact", []string{"dept", "batch-update-type", "--dept-ids", "1,2,3", "--target-dept-type", "COLLEGE"}},
{"college-contact", []string{"dept", "overview"}},
// employee (11)
{"college-contact", []string{"employee", "get-detail", "--staff-id", "staff001"}},
{"college-contact", []string{"employee", "add", "--emp-type", "TEACHER", "--main-dept-id", "12345", "--exclusive-account", "false"}},
{"college-contact", []string{"employee", "remove", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"employee", "change-type", "--staff-id", "staff001", "--emp-type", "STUDENT"}},
{"college-contact", []string{"employee", "change-dept", "--staff-id", "staff001", "--target-dept-id", "67890"}},
{"college-contact", []string{"employee", "send-active-sms", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-employees", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-unaccepted", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "list-unactive", "--dept-id", "12345"}},
{"college-contact", []string{"employee", "upgrade-status"}},
{"college-contact", []string{"employee", "start-upgrade"}},
// alumni (20)
{"college-contact", []string{"alumni", "get-dept-tree", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-info", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "list", "--alumni-dept-id", "12345", "--order-field", "NAME", "--ordering", "ASC"}},
{"college-contact", []string{"alumni", "query", "--staff-id", "staff001"}},
{"college-contact", []string{"alumni", "search", "--keyword", "张三"}},
{"college-contact", []string{"alumni", "list-unaccepted", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "create-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届"}},
{"college-contact", []string{"alumni", "update-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届计算机"}},
{"college-contact", []string{"alumni", "delete-dept", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "update-managers", "--alumni-dept-id", "12345", "--admin-user-ids", "u1,u2"}},
{"college-contact", []string{"alumni", "add-alumnus", "--dept-ids", "1,2", "--name", "张三", "--mobile", "13800138000"}},
{"college-contact", []string{"alumni", "update-alumnus", "--dept-ids", "1,2", "--staff-id", "s1", "--name", "张三"}},
{"college-contact", []string{"alumni", "remove-alumnus", "--staff-id", "s1", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "cancel-invite", "--alumni-dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"alumni", "create-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "disband-group", "--alumni-dept-id", "12345"}},
{"college-contact", []string{"alumni", "get-alumni-org-from-graduate"}},
{"college-contact", []string{"alumni", "create-alumni-org", "--org-name", "计算机校友会"}},
{"college-contact", []string{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}},
// graduate (16)
{"college-contact", []string{"graduate", "query-graduate-years"}},
{"college-contact", []string{"graduate", "query-graduate-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-graduate-sub-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-page-graduate-users", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "get-task-result", "--request-no", "req001"}},
{"college-contact", []string{"graduate", "get-alumni-org"}},
{"college-contact", []string{"graduate", "query-restore-sub-depts", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "query-dept-deleted-emps", "--dept-id", "12345"}},
{"college-contact", []string{"graduate", "search-graduate", "--keyword", "张三"}},
{"college-contact", []string{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "all-graduate", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "batch-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "delete-and-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "batch-delete-pending", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
{"college-contact", []string{"graduate", "batch-update-pending", "--dept-id", "12345", "--staff-ids", "s1,s2", "--graduate-year", "2026"}},
{"college-contact", []string{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}},
// group (9)
{"college-contact", []string{"group", "query-group-rule"}},
{"college-contact", []string{"group", "get-group-rule-schedule"}},
{"college-contact", []string{"group", "query-preview-data"}},
{"college-contact", []string{"group", "create-group-rule", "--name", "自动分组", "--tag-code", "TAG1", "--dept-type", "COLLEGE"}},
{"college-contact", []string{"group", "delete-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "enable-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "disable-group-rule", "--rule-id", "1"}},
{"college-contact", []string{"group", "set-group-rule-schedule"}},
{"college-contact", []string{"group", "execute-group-rule"}},
}
// 按产品分组的命令构建器
builders := map[string]func() *cobra.Command{
"edu-contact": newEduContactCommand,
"edu-group": newEduGroupCommand,
"edu-app": newEduAppCommand,
"edu-familygroup": newEduFamilyGroupCommand,
"college-contact": newCollegeContactCommand,
}
// 安装 dry-run caller
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
passed := 0
failed := 0
currentProduct := ""
for i, c := range allCmds {
if c.product != currentProduct {
currentProduct = c.product
fmt.Printf("\n══════════════════════════════════════════════════════\n")
fmt.Printf(" %s\n", strings.ToUpper(currentProduct))
fmt.Printf("══════════════════════════════════════════════════════\n")
}
buildFn, ok := builders[c.product]
if !ok {
t.Fatalf("unknown product: %s", c.product)
}
root := buildFn()
root.SetArgs(c.args)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
// Reset caller capture
caller.productID = ""
caller.tool = ""
caller.args = nil
err := root.Execute()
cmdPath := fmt.Sprintf("dws %s %s", c.product, strings.Join(c.args, " "))
if err != nil {
failed++
fmt.Printf(" [%3d] ✗ FAIL: %s\n", i+1, cmdPath)
fmt.Printf(" Error: %v\n", err)
t.Errorf("command %d failed: %s → %v", i+1, cmdPath, err)
} else {
passed++
fmt.Printf(" [%3d] ✓ %s\n", i+1, cmdPath)
if caller.tool != "" {
fmt.Printf(" → MCP: %s.%s\n", caller.productID, caller.tool)
}
}
}
fmt.Printf("\n══════════════════════════════════════════════════════\n")
fmt.Printf(" SUMMARY: %d passed, %d failed, %d total\n", passed, failed, passed+failed)
fmt.Printf("══════════════════════════════════════════════════════\n")
}
+18 -53
View File
@@ -15,21 +15,18 @@ func newFloatImageCmds() []*cobra.Command {
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
直接传 --file 可在一个命令中上传本地图片并创建浮动图片。
已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。
使用流程:
1. 先通过 media-upload 上传本地图片获取 resourceUrl
2. 再通过 create-float-image 将图片以浮动方式放置到工作表上
--range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。
--width / --height 为必填,单位像素,必须为正整数。
--offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`,
Example: ` # 直接从本地文件创建
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--file ./chart.png --range A1 --width 400 --height 300
# 高级用法:先上传图片获取 resourceUrl
Example: ` # 先上传图片获取 resourceUrl
dws sheet media-upload --node NODE_ID --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
# 再通过 --src 创建浮动图片
# 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl)
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
@@ -37,11 +34,7 @@ func newFloatImageCmds() []*cobra.Command {
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
filePath, src, err := validateFloatImageCreateInput(cmd)
if err != nil {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil {
return err
}
width, err := cmd.Flags().GetInt("width")
@@ -61,6 +54,7 @@ func newFloatImageCmds() []*cobra.Command {
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"src": mustGetFlag(cmd, "src"),
"range": mustGetFlag(cmd, "range"),
"width": width,
"height": height,
@@ -79,11 +73,7 @@ func newFloatImageCmds() []*cobra.Command {
}
toolArgs["offsetY"] = oy
}
if filePath != "" {
return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs)
}
toolArgs["src"] = src
return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs)
return callMCPTool("create_float_image", toolArgs)
},
}
DeclareLeafMetadata(createFloatImageCmd, LeafSpec{
@@ -99,30 +89,26 @@ func newFloatImageCmds() []*cobra.Command {
CLIPath: "sheet create-float-image",
PrimaryCLIPath: "sheet create-float-image",
},
Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"},
},
Selection: contract.SelectionSpec{
AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"},
AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"},
Examples: []string{"dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"},
{Name: "src", Property: "src", Required: boolPtr(false)},
},
},
})
createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一")
createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一")
createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)")
createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)")
createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)")
createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)")
@@ -231,8 +217,8 @@ floatImageId 可通过 list-float-images 获取。`,
Short: "更新浮动图片属性",
Long: `更新钉钉表格指定工作表中浮动图片的属性。
可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。
至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。
可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。
至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。
floatImageId 可通过 list-float-images 获取。`,
Example: ` # 移动浮动图片到新位置
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5
@@ -240,15 +226,10 @@ floatImageId 可通过 list-float-images 获取。`,
# 调整尺寸
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400
# 直接用本地文件替换图片
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
--file ./replacement.png
# 高级用法:用 media-upload 返回的 resourceUrl 替换图片
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
--src "/core/api/resources/img/xxxx..."`,
RunE: func(cmd *cobra.Command, args []string) error {
fileChanged := cmd.Flags().Changed("file")
srcChanged := cmd.Flags().Changed("src")
rangeChanged := cmd.Flags().Changed("range")
widthChanged := cmd.Flags().Changed("width")
@@ -256,17 +237,8 @@ floatImageId 可通过 list-float-images 获取。`,
oxChanged := cmd.Flags().Changed("offset-x")
oyChanged := cmd.Flags().Changed("offset-y")
if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
return fmt.Errorf("%s", floatImageUpdateFieldsError)
}
filePath, src, err := validateFloatImageUpdateInput(cmd)
if err != nil {
return err
}
if fileChanged {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil {
return err
}
if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个")
}
toolArgs := map[string]any{
@@ -275,7 +247,7 @@ floatImageId 可通过 list-float-images 获取。`,
"floatImageId": mustGetFlag(cmd, "float-image-id"),
}
if srcChanged {
toolArgs["src"] = src
toolArgs["src"], _ = cmd.Flags().GetString("src")
}
if rangeChanged {
toolArgs["range"], _ = cmd.Flags().GetString("range")
@@ -308,10 +280,7 @@ floatImageId 可通过 list-float-images 获取。`,
}
toolArgs["offsetY"] = oy
}
if fileChanged {
return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs)
}
return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs)
return callMCPTool("update_float_image", toolArgs)
},
}
DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{
@@ -328,7 +297,6 @@ floatImageId 可通过 list-float-images 获取。`,
PrimaryCLIPath: "sheet update-float-image",
},
Description: "更新浮动图片锚点、尺寸、偏移或资源路径。",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
@@ -342,15 +310,12 @@ floatImageId 可通过 list-float-images 获取。`,
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "file", Required: boolPtr(false)},
{Name: "src", Property: "src", Required: boolPtr(false)},
},
},
})
updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)")
updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用")
updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl")
updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法")
updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素")
-333
View File
@@ -1,333 +0,0 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
const (
floatImageCreateInputError = "--file 与 --src 必须且只能提供一个"
floatImageUpdateInputError = "--file 与 --src 不能同时提供"
floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个"
floatImagePlannedResource = "<resourceUrl-after-upload>"
floatImageUploadTimeout = 5 * time.Minute
)
type floatImageLocalFile struct {
file *os.File
path string
name string
mimeType string
size int64
}
type floatImageUploadInfo struct {
uploadURL string
resourceID string
resourceURL string
}
func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) {
filePath, _ := cmd.Flags().GetString("file")
src, _ := cmd.Flags().GetString("src")
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
return "", "", fmt.Errorf("--file 不能为空")
}
hasFile := strings.TrimSpace(filePath) != ""
hasSrc := strings.TrimSpace(src) != ""
if hasFile == hasSrc {
return "", "", fmt.Errorf("%s", floatImageCreateInputError)
}
return filePath, src, nil
}
func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) {
filePath, _ := cmd.Flags().GetString("file")
src, _ := cmd.Flags().GetString("src")
if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") {
return "", "", fmt.Errorf("%s", floatImageUpdateInputError)
}
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
return "", "", fmt.Errorf("--file 不能为空")
}
return filePath, src, nil
}
func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error {
local, err := openFloatImageLocalFile(filePath)
if err != nil {
return err
}
defer local.file.Close()
if deps.Caller.DryRun() {
return printFloatImageFileDryRun(toolName, toolArgs, local)
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json")
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size))
}
uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local)
if err != nil {
return err
}
if !jsonMode {
deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID)
deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL)
deps.Out.PrintInfo("[2/3] 上传本地图片...")
}
client := newFloatImageUploadClient()
if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil {
return err
}
if !jsonMode {
deps.Out.PrintInfo("[3/3] 提交浮动图片变更...")
}
toolArgs["src"] = uploadInfo.resourceURL
if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil {
return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err)
}
return nil
}
func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) {
return openFloatImageLocalFileWith(path, os.Stat, os.Open)
}
func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) {
info, err := stat(path)
if err != nil {
return nil, fmt.Errorf("无法读取 --file %q: %w", path, err)
}
if err := validateFloatImageFileInfo(info); err != nil {
return nil, err
}
file, err := open(path)
if err != nil {
return nil, fmt.Errorf("无法打开 --file %q: %w", path, err)
}
openedInfo, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err)
}
if err := validateFloatImageFileInfo(openedInfo); err != nil {
_ = file.Close()
return nil, err
}
return &floatImageLocalFile{
file: file,
path: path,
name: filepath.Base(path),
mimeType: inferMimeType(filepath.Base(path)),
size: openedInfo.Size(),
}, nil
}
func validateFloatImageFileInfo(info os.FileInfo) error {
if !info.Mode().IsRegular() {
return fmt.Errorf("--file 必须是普通文件")
}
if info.Size() <= 0 {
return fmt.Errorf("--file 不能为空文件")
}
return nil
}
func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error {
arguments := make(map[string]any, len(toolArgs)+1)
for key, value := range toolArgs {
arguments[key] = value
}
arguments["src"] = floatImagePlannedResource
stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": toolName,
"arguments": arguments,
"local_file": map[string]any{
"path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size,
},
"stages": stages,
})
}
deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更")
deps.Out.PrintKeyValue("文件", local.path)
deps.Out.PrintKeyValue("名称", local.name)
deps.Out.PrintKeyValue("类型", local.mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size))
deps.Out.PrintKeyValue("Tool", toolName)
deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> "))
return nil
}
func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) {
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": local.name,
"fileSize": float64(local.size),
"mimeType": local.mimeType,
})
if err != nil {
return floatImageUploadInfo{}, WrapError(err)
}
return parseFloatImageUploadInfo(result)
}
func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) {
if result == nil {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
text := ""
for _, content := range result.Content {
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
text = content.Text
break
}
}
if text == "" {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
type credential struct {
UploadURL string `json:"uploadUrl"`
ResourceID string `json:"resourceId"`
ResourceURL string `json:"resourceUrl"`
}
var envelope struct {
credential
Result *credential `json:"result"`
}
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
parsed := envelope.credential
if envelope.Result != nil {
parsed = *envelope.Result
}
parsed.UploadURL = strings.TrimSpace(parsed.UploadURL)
parsed.ResourceID = strings.TrimSpace(parsed.ResourceID)
parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL)
if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) {
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
}
if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil {
return floatImageUploadInfo{}, err
}
return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil
}
func invalidFloatImageCredentialError() error {
return &CLIError{
Code: CodeMCPToolError,
Message: "获取浮动图片上传凭证失败:响应格式无效",
Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务",
Operation: "doc/get_doc_attachment_upload_info",
}
}
func validFloatImageResourceID(value string) bool {
return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0
}
func validFloatImageResourceURL(value string) bool {
if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
return false
}
parsed, err := url.Parse(value)
return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == ""
}
// Keep this policy aligned with internal/transport.Client.isEndpointTrusted.
// It stays Sheet-private in V1 so local-file float-image support does not
// refactor the shared transport boundary selected by unrelated products.
func validateFloatImageUploadURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return invalidFloatImageUploadURLError()
}
if strings.EqualFold(parsed.Scheme, "https") {
return nil
}
if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) {
return invalidFloatImageUploadURLError()
}
return nil
}
func isFloatImageLoopbackHost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func invalidFloatImageUploadURLError() error {
return &CLIError{
Code: CodeInvalidParam,
Message: "浮动图片上传地址无效或不受信任",
Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP",
Operation: "sheet/float-image-upload",
}
}
func newFloatImageUploadClient() *http.Client {
return &http.Client{
Timeout: floatImageUploadTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile:
// both have incompatible retry/status/host behavior, and the Doc helper can
// copy a signed URL or response body into errors. Keep this fixed-message PUT
// until a shared secure-upload contract can replace all upload implementations.
func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error {
if client == nil {
return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用")
}
if _, err := local.file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file)
if err != nil {
return fmt.Errorf("浮动图片文件上传失败:无法创建请求")
}
request.ContentLength = local.size
request.Header.Set("Content-Type", local.mimeType)
response, err := client.Do(request)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr)
}
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
return fmt.Errorf("浮动图片文件上传失败:已超过传输时限")
}
return fmt.Errorf("浮动图片文件上传失败:网络请求失败")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
if response.StatusCode != http.StatusOK {
return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode)
}
return nil
}
@@ -1,20 +0,0 @@
//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly
package helpers
import (
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "image.fifo")
if err := syscall.Mkfifo(path, 0o600); err != nil {
t.Fatalf("mkfifo: %v", err)
}
if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("FIFO error = %v", err)
}
}
@@ -1,695 +0,0 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type floatImageToolCall struct {
product string
tool string
args map[string]any
}
type floatImageTestCaller struct {
dryRun bool
format string
calls []floatImageToolCall
call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error)
}
func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
copyArgs := make(map[string]any, len(args))
for key, value := range args {
copyArgs[key] = value
}
index := len(c.calls)
c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs})
if c.call != nil {
return c.call(ctx, product, tool, args, index)
}
return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil
}
func (c *floatImageTestCaller) Format() string {
if c.format == "" {
return "json"
}
return c.format
}
func (c *floatImageTestCaller) DryRun() bool { return c.dryRun }
func (*floatImageTestCaller) Fields() string { return "" }
func (*floatImageTestCaller) JQ() string { return "" }
type floatImageRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func floatImageTextResult(text string) *edition.ToolResult {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
}
func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult {
credential := map[string]any{
"uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1",
}
payload := any(credential)
if nested {
payload = map[string]any{"result": credential}
}
raw, _ := json.Marshal(payload)
return floatImageTextResult(string(raw))
}
func floatImageCommandForTest(t *testing.T, name string) *cobra.Command {
t.Helper()
for _, command := range newFloatImageCmds() {
if command.Name() == name {
return command
}
}
t.Fatalf("missing float-image command %s", name)
return nil
}
func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) {
t.Helper()
for name, value := range values {
if err := command.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
}
func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) {
t.Helper()
if output == nil {
output = io.Discard
}
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)})
previousArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = previousArgs })
}
func writeFloatImageFixture(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile {
t.Helper()
local, err := openFloatImageLocalFile(path)
if err != nil {
t.Fatalf("open fixture: %v", err)
}
t.Cleanup(func() { _ = local.file.Close() })
return local
}
func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) {
create := floatImageCommandForTest(t, "create-float-image")
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("missing create input error = %v", err)
}
setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"})
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("conflicting create input error = %v", err)
}
createEmptyFile := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"})
if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("empty create file error = %v", err)
}
fileOnly := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "})
if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" {
t.Fatalf("file-only input = %q/%q/%v", file, src, err)
}
srcOnly := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "})
if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " {
t.Fatalf("src-only input = %q/%q/%v", file, src, err)
}
updateBoth := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"})
if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
t.Fatalf("conflicting update input error = %v", err)
}
updateEmptyFile := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "})
if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("empty update file error = %v", err)
}
updateSrc := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, updateSrc, map[string]string{"src": ""})
if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" {
t.Fatalf("src update compatibility = %q/%q/%v", file, src, err)
}
}
func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) {
validPath := writeFloatImageFixture(t, "chart.png", "png-data")
local, err := openFloatImageLocalFile(validPath)
if err != nil {
t.Fatal(err)
}
if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) {
t.Fatalf("local metadata = %#v", local)
}
_ = local.file.Close()
if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("missing file error = %v", err)
}
if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("directory error = %v", err)
}
emptyPath := writeFloatImageFixture(t, "empty.png", "")
if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
t.Fatalf("empty error = %v", err)
}
validInfo, err := os.Stat(validPath)
if err != nil {
t.Fatal(err)
}
statValid := func(string) (os.FileInfo, error) { return validInfo, nil }
openFailure := errors.New("open sentinel")
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) {
t.Fatalf("open error = %v", err)
}
closed, err := os.Open(validPath)
if err != nil {
t.Fatal(err)
}
_ = closed.Close()
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") {
t.Fatalf("opened stat error = %v", err)
}
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("descriptor type error = %v", err)
}
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
t.Fatalf("descriptor size error = %v", err)
}
}
func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) {
invalidResults := []*edition.ToolResult{
nil,
{},
{Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}},
floatImageTextResult("{"),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`),
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`),
floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`),
}
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
for index, result := range invalidResults {
_, err := parseFloatImageUploadInfo(result)
if err == nil {
t.Fatalf("invalid result %d succeeded", index)
}
if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") {
t.Fatalf("invalid result %d leaked secret: %v", index, err)
}
}
top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`)
info, err := parseFloatImageUploadInfo(top)
if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" {
t.Fatalf("top-level credential = %#v, %v", info, err)
}
nested := &edition.ToolResult{Content: []edition.ContentBlock{
{Type: "text", Text: " "},
{Type: "image", Text: "ignored"},
{Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`},
}}
info, err = parseFloatImageUploadInfo(nested)
if err != nil || info.resourceID != "rid-nested" {
t.Fatalf("nested credential = %#v, %v", info, err)
}
for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} {
if validFloatImageResourceURL(value) {
t.Errorf("resource URL %q unexpectedly valid", value)
}
}
if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") {
t.Fatal("resource identifier validation mismatch")
}
for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} {
if err := validateFloatImageUploadURL(rawURL); err == nil {
t.Errorf("upload URL %q unexpectedly valid", rawURL)
}
}
if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil {
t.Fatalf("HTTPS URL rejected: %v", err)
}
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} {
if err := validateFloatImageUploadURL(rawURL); err != nil {
t.Errorf("loopback URL %q rejected: %v", rawURL, err)
}
}
if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") {
t.Fatalf("non-loopback policy mismatch: %v", err)
}
}
func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "streamed-image")
t.Run("nil client", func(t *testing.T) {
local := openFloatImageFixture(t, path)
if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil {
t.Fatal("nil client succeeded")
}
})
t.Run("closed file", func(t *testing.T) {
local := openFloatImageFixture(t, path)
_ = local.file.Close()
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") {
t.Fatalf("closed file error = %v", err)
}
})
t.Run("invalid request", func(t *testing.T) {
local := openFloatImageFixture(t, path)
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") {
t.Fatalf("request error = %v", err)
}
})
t.Run("transport secret redacted", func(t *testing.T) {
local := openFloatImageFixture(t, path)
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport signed-secret https://upload.invalid")
})}
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local)
if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") {
t.Fatalf("transport error = %v", err)
}
})
t.Run("cancel", func(t *testing.T) {
local := openFloatImageFixture(t, path)
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) {
return nil, request.Context().Err()
})}
err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local)
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
})
t.Run("client deadline", func(t *testing.T) {
local := openFloatImageFixture(t, path)
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local)
if err == nil || !strings.Contains(err.Error(), "超过传输时限") {
t.Fatalf("deadline error = %v", err)
}
})
t.Run("non-200 body redacted", func(t *testing.T) {
local := openFloatImageFixture(t, path)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(response, "response-body-secret")
}))
defer server.Close()
err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local)
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("status error = %v", err)
}
})
t.Run("stream success", func(t *testing.T) {
local := openFloatImageFixture(t, path)
var method, contentType, body string
var contentLength int64
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
method = request.Method
contentType = request.Header.Get("Content-Type")
contentLength = request.ContentLength
raw, _ := io.ReadAll(request.Body)
body = string(raw)
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil {
t.Fatal(err)
}
if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" {
t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body)
}
})
t.Run("redirect not followed", func(t *testing.T) {
local := openFloatImageFixture(t, path)
destinationCalls := 0
destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ }))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Location", destination.URL+"/location-secret")
response.WriteHeader(http.StatusFound)
}))
defer source.Close()
client := newFloatImageUploadClient()
if client.Timeout != floatImageUploadTimeout {
t.Fatalf("timeout = %v", client.Timeout)
}
err := putFloatImageFileWithClient(context.Background(), client, source.URL, local)
if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 {
t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls)
}
})
}
func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "image")
for _, format := range []string{"json", "table"} {
t.Run(format, func(t *testing.T) {
caller := &floatImageTestCaller{dryRun: true, format: format}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run remote calls = %d", len(caller.calls))
}
if format == "json" {
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run: %v; output=%s", err, output.String())
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
if _, exists := payload["preview_kind"]; exists {
t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload)
}
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") {
t.Fatalf("pretty dry-run = %s", output.String())
}
})
}
for _, format := range []string{"json", "table"} {
t.Run("update-"+format, func(t *testing.T) {
caller := &floatImageTestCaller{dryRun: true, format: format}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("update dry-run remote calls = %d", len(caller.calls))
}
if format == "json" {
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode update dry-run: %v; output=%s", err, output.String())
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false {
t.Fatalf("update dry-run payload = %#v", payload)
}
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") {
t.Fatalf("update pretty dry-run = %s", output.String())
}
})
}
}
func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) {
path := writeFloatImageFixture(t, "chart.png", "command-image")
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Run("create src", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{
"node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80",
})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" {
t.Fatalf("src calls = %#v", caller.calls)
}
})
t.Run("create and update file", func(t *testing.T) {
var uploadBodies []string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
raw, _ := io.ReadAll(request.Body)
uploadBodies = append(uploadBodies, string(raw))
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
for _, commandName := range []string{"create-float-image", "update-float-image"} {
t.Run(commandName, func(t *testing.T) {
caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
if product != "doc" || tool != "get_doc_attachment_upload_info" {
t.Fatalf("credential route = %s/%s", product, tool)
}
return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil
}
return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil
}}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, commandName)
flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}
if commandName == "create-float-image" {
flags["range"], flags["width"], flags["height"] = "A1", "100", "80"
} else {
flags["float-image-id"], flags["width"] = "fi-1", "120"
}
setFloatImageFlags(t, command, flags)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") {
t.Fatalf("workflow calls = %#v", caller.calls)
}
if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" {
t.Fatalf("workflow args = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil {
t.Fatalf("final output = %s, %v", output.String(), err)
}
})
}
if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" {
t.Fatalf("upload bodies = %#v", uploadBodies)
}
})
t.Run("pretty progress", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
defer server.Close()
caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
return floatImageCredentialResult(server.URL, false), nil
}
return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil
}}
var output bytes.Buffer
installFloatImageDeps(t, caller, &output)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} {
if !strings.Contains(output.String(), marker) {
t.Fatalf("pretty output missing %q: %s", marker, output.String())
}
}
})
t.Run("validation and local error", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
create := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"})
if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
t.Fatalf("create validation = %v", err)
}
missing := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"})
if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("local error = %v", err)
}
update := floatImageCommandForTest(t, "update-float-image")
if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) {
t.Fatalf("update fields error = %v", err)
}
conflict := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"})
if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
t.Fatalf("update conflict = %v", err)
}
missingIdentity := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path})
if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 {
t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls))
}
})
t.Run("credential and put failures", func(t *testing.T) {
credentialCause := errors.New("credential transport")
caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return nil, credentialCause
}}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); !errors.Is(err, credentialCause) {
t.Fatalf("credential error = %v", err)
}
secret := "credential-response-secret"
malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return floatImageTextResult(secret), nil
}}
installFloatImageDeps(t, malformed, io.Discard)
command = floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) {
t.Fatalf("malformed credential error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(response, "put-body-secret")
}))
defer server.Close()
putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
}}
installFloatImageDeps(t, putCaller, io.Discard)
command = floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls))
}
})
t.Run("partial final failure", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
defer server.Close()
finalCause := errors.New("final operation sentinel")
caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
if index == 0 {
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
}
return nil, finalCause
}}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "create-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
err := command.RunE(command, nil)
if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") {
t.Fatalf("partial failure = %v", err)
}
})
t.Run("update move only", func(t *testing.T) {
caller := &floatImageTestCaller{}
installFloatImageDeps(t, caller, io.Discard)
command := floatImageCommandForTest(t, "update-float-image")
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"})
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" {
t.Fatalf("move-only calls = %#v", caller.calls)
}
})
}
func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) {
path := writeFloatImageFixture(t, "chart.webp", "webp")
local := openFloatImageFixture(t, path)
caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) {
if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" {
t.Fatalf("route/context = %s/%s/%v", product, tool, ctx)
}
if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) {
t.Fatalf("credential args = %#v", args)
}
return floatImageCredentialResult("https://upload.invalid/path", false), nil
}}
installFloatImageDeps(t, caller, io.Discard)
info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local)
if err != nil || info.resourceID != "rid-1" {
t.Fatalf("resolve info = %#v/%v", info, err)
}
cause := fmt.Errorf("timeout synthetic")
caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
return nil, cause
}
if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) {
t.Fatalf("resolve error = %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) {
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatalf("credential context error = %v", ctx.Err())
}
return nil, ctx.Err()
}
if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled credential error = %v", err)
}
}
func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) {
client := newFloatImageUploadClient()
if client.Timeout != 5*time.Minute || client.CheckRedirect == nil {
t.Fatalf("upload client = %#v", client)
}
}
+5 -13
View File
@@ -144,25 +144,17 @@ func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T
// Large-CP failure is not a pageable success. Both the raw csv-get path and
// both get_cell_infos paths must retain the backend code and safe user message
// exactly, matching the generic get_all_sheets error-classification path.
//
// 两条路径的 Message 形态不同:csv-get / cell-infos-direct 走终端展示路径
// (callMCPToolInternalOptsContext),业务错误展示为提取的 errorMessage 加
// "(code: ...)" 后缀——后端错误码必须保持可见;range read 走数据编排路径
// (parseMCPToolTextResult),下游(如 drive list --depth 的限流重试)需要
// 从 Message 反解析 errorCode,因此必须保留原始 JSON payload。
func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}`
const wantDisplay = "The workbook data is too large to process. Use a smaller copy or split the workbook, then try again. (code: forbidden.document.sizeOverLimit)"
tests := []struct {
name string
args []string
tool string
direct bool
want string
}{
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv", want: wantDisplay},
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos", want: response},
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true, want: wantDisplay},
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"},
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"},
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true},
}
for _, test := range tests {
@@ -188,8 +180,8 @@ func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError {
t.Fatalf("error = %T %v, want MCP tool error", err, err)
}
if cliErr.Message != test.want {
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, test.want)
if cliErr.Message != response {
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
}
for _, want := range []string{
"forbidden.document.sizeOverLimit",
+18 -64
View File
@@ -96,10 +96,7 @@ func newTodoCommand() *cobra.Command {
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
toolArgs := map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": flagOrFallback(cmd, "title", "subject", "content"),
@@ -114,11 +111,9 @@ func newTodoCommand() *cobra.Command {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
@@ -159,7 +154,7 @@ func newTodoCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
{Name: "title", Property: "PersonalTodoCreateVO.subject"},
@@ -195,10 +190,7 @@ func newTodoCommand() *cobra.Command {
}
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
toolArgs := map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": flagOrFallback(cmd, "title", "subject", "content"),
@@ -214,11 +206,9 @@ func newTodoCommand() *cobra.Command {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
}
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
@@ -259,7 +249,7 @@ func newTodoCommand() *cobra.Command {
},
Parameters: []contract.ParamDecl{
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
{Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"},
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
@@ -386,11 +376,9 @@ func newTodoCommand() *cobra.Command {
inner["dueTime"] = ms
}
if v, _ := cmd.Flags().GetString("priority"); v != "" {
n, err := parseTodoPriority(v)
if err != nil {
return err
if n, err := strconv.Atoi(v); err == nil {
inner["priority"] = n
}
inner["priority"] = n
}
if v, _ := cmd.Flags().GetString("done"); v != "" {
inner["isDone"] = v == "true"
@@ -642,10 +630,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
return callMCPTool("add_task_executors", map[string]any{
"todoExecutorsAddRequest": map[string]any{
"taskId": mustGetFlag(cmd, "task-id"),
@@ -687,7 +672,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
},
},
Parameters: []contract.ParamDecl{
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds"},
{Name: "task-id", Property: "todoExecutorsAddRequest.taskId"},
},
},
@@ -703,10 +688,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
return err
}
executorsStr := mustGetFlag(cmd, "executors")
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
if err != nil {
return err
}
executorIds := parseExecutorIds(executorsStr)
return callMCPTool("remove_task_executors", map[string]any{
"todoExecutorsRemoveRequest": map[string]any{
"taskId": mustGetFlag(cmd, "task-id"),
@@ -747,7 +729,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
},
},
Parameters: []contract.ParamDecl{
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"},
{Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"},
},
},
@@ -1207,14 +1189,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
}
todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)")
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)")
todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)")
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
@@ -1253,9 +1235,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
_ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at")
todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)")
todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
@@ -1911,34 +1893,6 @@ func parseExecutorIds(s string) []string {
return ids
}
func parseRequiredTodoIDs(flagName, value string) ([]string, error) {
ids := parseExecutorIds(value)
if len(ids) > 0 {
return ids, nil
}
return nil, apperrors.NewValidation(
fmt.Sprintf("--%s 至少需要一个非空 userId", flagName),
apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")),
apperrors.WithHint(fmt.Sprintf("使用 --%s <USER_ID>[,<USER_ID>...] 指定至少一个执行人", flagName)),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
func parseTodoPriority(value string) (int, error) {
priority, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil {
return priority, nil
}
return 0, apperrors.NewValidation(
"--priority 必须是整数",
apperrors.WithReason("invalid_priority"),
apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
type todoLocalFileMeta struct {
LocalPath string
FileName string
@@ -6,11 +6,8 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error {
@@ -47,11 +44,9 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
errorCases := [][]string{
{"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate},
{"task", "create", "--title", "x", "--executors", "u", "--due", "bad"},
{"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
{"task", "list", "--role-types", "invalid"},
{"task", "list", "--plan-finish-date-start", "bad"},
{"task", "list", "--plan-finish-date-end", "bad"},
@@ -64,8 +59,9 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
validCases := [][]string{
{"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"},
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"},
{"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
{"task", "list", "--size", "bad"},
{"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate},
}
@@ -79,63 +75,6 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) {
tests := []struct {
name string
reason string
args []string
}{
{name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}},
{name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}},
{name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}},
{name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}},
{name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeTodoEdge(t, caller, test.args...)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != test.reason {
t.Fatalf("error = %T %v, want reason %q", err, err, test.reason)
}
if typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted)
}
if caller.calls != 0 {
t.Fatalf("invalid input made %d MCP call(s)", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) {
for _, priority := range []string{"20", "25"} {
t.Run(priority, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil {
t.Fatalf("create priority %s: %v", priority, err)
}
if caller.calls != 1 || caller.tool != "create_personal_todo" {
t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool)
}
request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any)
if !ok || request["priority"] != mustTodoAtoi(t, priority) {
t.Fatalf("request = %#v, want priority %s", caller.args, priority)
}
})
}
}
func mustTodoAtoi(t *testing.T, value string) int {
t.Helper()
parsed, err := strconv.Atoi(value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return parsed
}
func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) {
errorCases := [][]string{
{"task", "create"},
@@ -204,7 +143,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
errorCases := [][]string{
{"task", "update", "--task-id", "1", "--remind-at", validDate},
{"task", "update", "--task-id", "1", "--due", "bad"},
{"task", "update", "--task-id", "1", "--priority", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
@@ -217,6 +155,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
}
validCases := [][]string{
{"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"},
{"task", "update", "--task-id", "1", "--priority", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate},
}
+61 -158
View File
@@ -529,20 +529,11 @@ func newWikiCommand() *cobra.Command {
memberAddCmd := &cobra.Command{
Use: "add",
Short: "添加知识库成员",
Args: cobra.NoArgs,
Long: `为指定知识库添加一个或多个成员,并授予指定角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色(大小写不敏感):
支持的角色 (--role)(必须大写):
MANAGER 管理员,可读写、管理成员
EDITOR 编辑者,可查看、编辑、上传内容
DOWNLOADER 查看下载者,可查看并下载内容
@@ -550,58 +541,36 @@ func newWikiCommand() *cobra.Command {
注意:
- OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
- 单次请求最多 30 个成员,超出请分批调用。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
支持通过 --workspace 传入知识库 ID 或知识库 URL,系统自动识别。
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
dws contact user search --keyword "姓名"`,
Example: ` dws wiki member add --workspace <workspaceId> --users uid1 --role READER
dws wiki member add --workspace <workspaceId> --users uid1,uid2,uid3 --role EDITOR
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER
dws wiki member add --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
dws wiki member add --workspace <workspaceId> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"workspaceId": workspaceID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
if role == "OWNER" {
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
}
if len(members) > 0 {
for _, m := range members {
if r, _ := m["roleId"].(string); r == "OWNER" {
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
}
}
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
if role == "OWNER" {
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = role
toolArgs["userIds"] = userIds
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
return callMCPTool("add_member", toolArgs)
return callMCPTool("add_member", map[string]any{
"workspaceId": workspaceID,
"roleId": role,
"userIds": userIds,
})
},
}
DeclareLeafMetadata(memberAddCmd, LeafSpec{
@@ -636,8 +605,6 @@ func newWikiCommand() *cobra.Command {
},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -646,30 +613,17 @@ func newWikiCommand() *cobra.Command {
})
memberAddCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
memberAddCmd.Flags().String("user", "", "")
_ = memberAddCmd.Flags().MarkHidden("user")
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
memberAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
memberAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
memberUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新知识库成员权限",
Args: cobra.NoArgs,
Long: `更新指定知识库已有成员的角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色(大小写不敏感):
支持的角色 (--role)(必须大写):
MANAGER 管理员
EDITOR 编辑者
DOWNLOADER 查看下载者
@@ -678,45 +632,28 @@ func newWikiCommand() *cobra.Command {
注意:
- OWNER 角色不可通过此接口变更。
- 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
仅可更新已存在成员关系的成员,新增成员请使用 dws wiki member add。`,
Example: ` dws wiki member update --workspace <workspaceId> --users uid1 --role EDITOR
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER
dws wiki member update --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws wiki member update --workspace <workspaceId> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
toolArgs := map[string]any{"workspaceId": workspaceID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
return callMCPTool("update_member", toolArgs)
return callMCPTool("update_member", map[string]any{
"workspaceId": workspaceID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"userIds": userIds,
})
},
}
DeclareLeafMetadata(memberUpdateCmd, LeafSpec{
@@ -745,8 +682,6 @@ func newWikiCommand() *cobra.Command {
Examples: []string{"dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
@@ -755,12 +690,10 @@ func newWikiCommand() *cobra.Command {
})
memberUpdateCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
memberUpdateCmd.Flags().String("user", "", "")
_ = memberUpdateCmd.Flags().MarkHidden("user")
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
memberUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
memberUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
memberListCmd := &cobra.Command{
Use: "list",
@@ -768,15 +701,12 @@ func newWikiCommand() *cobra.Command {
Short: "查询知识库成员列表",
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
操作者需满足该知识库配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
ORG 类型授权不会出现在查询结果中。`,
Example: ` dws wiki member list --workspace <workspaceId>
dws wiki member list --workspace <workspaceId> --limit 50
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR
dws wiki member list --workspace <workspaceId> --next-token <上次返回的 nextToken>`,
dws wiki member list --workspace <workspaceId> --limit 100
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
@@ -785,13 +715,17 @@ ORG 类型授权不会出现在查询结果中。`,
toolArgs := map[string]any{
"workspaceId": workspaceID,
}
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
if limit > 0 {
if limit > 50 {
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
}
toolArgs["maxResults"] = limit
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
@@ -829,79 +763,50 @@ ORG 类型授权不会出现在查询结果中。`,
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "limit", Property: "maxResults"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
memberListCmd.Flags().Int("max-results", 0, "")
_ = memberListCmd.Flags().MarkHidden("max-results")
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
memberListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
memberRemoveCmd := &cobra.Command{
Use: "remove",
Short: "移除知识库成员",
Long: `从指定知识库中移除一个或多个成员。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
Long: `从指定知识库中移除一个或多个成员(仅支持 USER 类型)。
移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
注意:
- OWNER 角色不可通过此接口移除。
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
- 单次请求最多 30 个成员,超出请分批调用。`,
Example: ` dws wiki member remove --workspace <workspaceId> --users uid1
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3
dws wiki member remove --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{"workspaceId": workspaceID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
return callMCPTool("remove_member", toolArgs)
return callMCPTool("remove_member", map[string]any{
"workspaceId": workspaceID,
"userIds": userIds,
})
},
}
DeclareLeafMetadata(memberRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销整个
// 知识库容器级别的成员访问,部门/群聊/角色组还可能间接影响大量用户,
// 与删除同级的 destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
@@ -911,14 +816,14 @@ ORG 类型授权不会出现在查询结果中。`,
CLIPath: "wiki member remove",
PrimaryCLIPath: "wiki member remove",
},
Description: "从指定知识库中移除一个或多个成员",
Description: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "wiki", RPCName: "remove_member"},
},
Selection: contract.SelectionSpec{
AgentSummary: "从指定知识库中移除一个或多个成员",
AgentSummary: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
UseWhen: []string{"从知识库移除成员访问(离职/清理)时"},
AvoidWhen: []string{
"改角色用 update;节点级权限用 drive permission remove",
@@ -927,17 +832,15 @@ ORG 类型授权不会出现在查询结果中。`,
Examples: []string{"dws wiki member remove --workspace <WS_ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
memberRemoveCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
memberRemoveCmd.Flags().String("user", "", "")
_ = memberRemoveCmd.Flags().MarkHidden("user")
memberRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
// member 子命令的 --workspace-id 隐藏别名(LLMs derive from API field "workspaceId")
memberAliasCmds := []*cobra.Command{memberAddCmd, memberUpdateCmd, memberListCmd, memberRemoveCmd}
@@ -179,7 +179,6 @@ func TestWukongSyncAgoalCommands(t *testing.T) {
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
{[]string{"scorecard", "search-entities"}, []string{"keyword", "page", "page-size", "request-id"}},
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
+22 -127
View File
@@ -21,7 +21,6 @@ const (
CommandMigrationMove = "command_move"
CommandMigrationFlagExtraction = "flag_extraction"
CommandMigrationAvailability = "schema_availability_hardening"
)
// CommandMigrationManifest governs compatibility-preserving surface moves that
@@ -65,12 +64,6 @@ type CommandMigrationSchema struct {
SourceToolID string `json:"source_tool_id"`
ReplacementToolID string `json:"replacement_tool_id"`
Parameters []CommandParameterMigration `json:"parameters"`
Availability *CommandAvailabilityChange `json:"availability,omitempty"`
}
type CommandAvailabilityChange struct {
Before string `json:"before"`
After string `json:"after"`
}
type CommandParameterMigration struct {
@@ -133,23 +126,14 @@ func (m CommandMigrationManifest) Validate() error {
}
func (m CommandMigration) validate() error {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
return fmt.Errorf("invalid kind %q", m.Kind)
}
if m.Kind == CommandMigrationAvailability {
if !isExactCommandPath(m.Legacy.Command) {
return fmt.Errorf("legacy must be an exact command path rooted at dws")
}
if m.Replacement != (CommandMigrationSide{}) {
return fmt.Errorf("schema_availability_hardening must not declare replacement")
}
} else {
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
}
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
}
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration must include a non-empty trimmed reason")
@@ -157,26 +141,21 @@ func (m CommandMigration) validate() error {
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
states := map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
}
if m.Kind != CommandMigrationAvailability {
states["replacement before"] = m.Replacement.Before
states["replacement after"] = m.Replacement.After
}
for label, state := range states {
for label, state := range map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
"replacement before": m.Replacement.Before,
"replacement after": m.Replacement.After,
} {
if err := state.validate(label); err != nil {
return err
}
}
if m.Kind != CommandMigrationAvailability &&
(!m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable) {
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
return fmt.Errorf("legacy command must remain runnable and start visible")
}
if m.Kind != CommandMigrationAvailability &&
(m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden) {
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
}
if err := m.Schema.validate(m.Kind); err != nil {
@@ -235,13 +214,6 @@ func (m CommandMigration) validate() error {
if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
}
case CommandMigrationAvailability:
if !isVisibleToHiddenAvailabilityMigration(m) && !isCompatibilityVisibleAvailabilityMigration(m) {
return fmt.Errorf("schema_availability_hardening legacy command must migrate exactly from visible to hidden or remain compatibility-visible")
}
if m.LegacyFlag != (CommandMigrationFlag{}) {
return fmt.Errorf("schema_availability_hardening must not declare legacy_flag")
}
}
return nil
}
@@ -287,8 +259,9 @@ func (f CommandMigrationFlag) validate() error {
func (s CommandMigrationSchema) validate(kind string) error {
for label, value := range map[string]string{
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
"replacement_tool_id": s.ReplacementToolID,
} {
if !isExactSchemaIdentifier(value) {
return fmt.Errorf("schema %s must be an exact identifier", label)
@@ -297,21 +270,6 @@ func (s CommandMigrationSchema) validate(kind string) error {
if s.Parameters == nil {
return fmt.Errorf("schema parameters must be an array")
}
if kind == CommandMigrationAvailability {
if s.ReplacementToolID != "" || len(s.Parameters) != 0 {
return fmt.Errorf("schema_availability_hardening must not declare a replacement tool or parameter mappings")
}
if s.Availability == nil || s.Availability.Before != "available" || s.Availability.After != "unavailable" {
return fmt.Errorf("schema_availability_hardening requires availability available to unavailable")
}
return nil
}
if !isExactSchemaIdentifier(s.ReplacementToolID) {
return fmt.Errorf("schema replacement_tool_id must be an exact identifier")
}
if s.Availability != nil {
return fmt.Errorf("%s must not declare schema availability", kind)
}
seenFrom := map[string]bool{}
seenTo := map[string]bool{}
seenConstantProperty := map[string]bool{}
@@ -386,9 +344,6 @@ func (m CommandMigration) key() string {
}
func (m CommandMigration) displayKey() string {
if m.Kind == CommandMigrationAvailability {
return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command)
}
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
}
@@ -446,7 +401,7 @@ func AuthorizeCommandMigrations(
func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
commands := commandIndex(snapshot)
for _, migration := range manifest.Migrations {
if migration.Kind != CommandMigrationMove && migration.Kind != CommandMigrationAvailability {
if migration.Kind != CommandMigrationMove {
continue
}
if _, present := commands[migration.Legacy.Command]; !present {
@@ -454,7 +409,7 @@ func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigratio
}
for _, command := range snapshot.Commands {
if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
return fmt.Errorf("%s legacy command %q must be a leaf", migration.Kind, migration.Legacy.Command)
return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
}
}
}
@@ -483,42 +438,17 @@ func evaluateCommandMigrationLifecycle(
for _, approved := range authority.Migrations {
basePhase := matchCommandMigrationPhase(mergeBase, approved)
wantBase := commandMigrationBefore
if approved.State == CommandMigrationConsumed && !isCompatibilityVisibleAvailabilityMigration(approved) {
if approved.State == CommandMigrationConsumed {
wantBase = commandMigrationAfter
}
if basePhase != wantBase {
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameCommandMigrationApproval(approved, proposed) &&
!isPendingAvailabilityCompatibilityRefinement(approved, proposed) {
if exists && !sameCommandMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
}
currentPhase := matchCommandMigrationPhase(current, approved)
if isCompatibilityVisibleAvailabilityMigration(approved) {
if currentPhase != commandMigrationBefore {
return nil, fmt.Errorf("candidate drifted from compatibility-visible command migration %s", approved.displayKey())
}
if !exists {
return nil, fmt.Errorf("candidate must retain compatibility-visible command migration %s", approved.displayKey())
}
switch approved.State {
case CommandMigrationPending:
switch proposed.State {
case CommandMigrationPending:
continue
case CommandMigrationConsumed:
authorizations = append(authorizations, approved)
continue
}
case CommandMigrationConsumed:
if proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed compatibility-visible command migration %s back to pending", approved.displayKey())
}
authorizations = append(authorizations, approved)
continue
}
}
switch approved.State {
case CommandMigrationPending:
if !exists {
@@ -591,40 +521,9 @@ func sameCommandMigrationApproval(left, right CommandMigration) bool {
return reflect.DeepEqual(left, right)
}
func isVisibleToHiddenAvailabilityMigration(migration CommandMigration) bool {
return migration.Kind == CommandMigrationAvailability &&
migration.Legacy.Before == (CommandMigrationState{Present: true, Runnable: true}) &&
migration.Legacy.After == (CommandMigrationState{Present: true, Runnable: true, Hidden: true})
}
func isCompatibilityVisibleAvailabilityMigration(migration CommandMigration) bool {
visible := CommandMigrationState{Present: true, Runnable: true}
return migration.Kind == CommandMigrationAvailability &&
migration.Legacy.Before == visible && migration.Legacy.After == visible
}
func isPendingAvailabilityCompatibilityRefinement(approved, proposed CommandMigration) bool {
if approved.State != CommandMigrationPending || proposed.State != CommandMigrationPending ||
!isVisibleToHiddenAvailabilityMigration(approved) ||
!isCompatibilityVisibleAvailabilityMigration(proposed) {
return false
}
proposed.Legacy.After = approved.Legacy.After
return reflect.DeepEqual(approved, proposed)
}
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
commands := commandIndex(snapshot)
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
if migration.Kind == CommandMigrationAvailability {
if legacy == migration.Legacy.Before {
return commandMigrationBefore
}
if legacy == migration.Legacy.After {
return commandMigrationAfter
}
return commandMigrationPartial
}
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
@@ -730,10 +629,6 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationAvailability:
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationFlagExtraction:
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
return true
@@ -186,7 +186,6 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
want string
}{
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
{"replacement identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ReplacementToolID = "bad/id" }, "replacement_tool_id"},
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
@@ -242,153 +241,6 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageSchemaAvailabilityMigrationLifecycle(t *testing.T) {
pending := availabilityCommandMigrationManifest(CommandMigrationPending)
consumed := availabilityCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
before := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
testCommand("dws devapp +event-list"),
)
after := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
)
got, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || len(got) != 1 {
t.Fatalf("availability lifecycle authorizations=%#v error=%v", got, err)
}
report, err := CompareAllWithInterfaceMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("availability interface report compatible=%v error=%v report=%#v", report.Compatible, err, report)
}
if got, err := AuthorizeCommandMigrations(
before,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err != nil || len(got) != 0 {
t.Fatalf("candidate-added pending migration must remain inert: %#v, %v", got, err)
}
valid := pending.Migrations[0]
compatibilityVisible := cloneCommandMigration(valid)
compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before
if err := compatibilityVisible.validate(); err != nil {
t.Fatalf("compatibility-visible availability migration must validate: %v", err)
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"legacy path", func(m *CommandMigration) { m.Legacy.Command = "dws devapp *" }, "legacy must be an exact"},
{"replacement", func(m *CommandMigration) { m.Replacement.Command = "dws devapp other" }, "must not declare replacement"},
{"absent after", func(m *CommandMigration) { m.Legacy.After = CommandMigrationState{} }, "visible to hidden or remain compatibility-visible"},
{"legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" }, "must not declare legacy_flag"},
{"missing availability", func(m *CommandMigration) { m.Schema.Availability = nil }, "requires availability"},
{"wrong availability", func(m *CommandMigration) { m.Schema.Availability.After = "available" }, "requires availability"},
{"parameter mapping", func(m *CommandMigration) {
m.Schema.Parameters = []CommandParameterMigration{{From: "cursor", To: "next-token"}}
}, "must not declare"},
} {
t.Run(test.name, func(t *testing.T) {
migration := cloneCommandMigration(valid)
if valid.Schema.Availability != nil {
change := *valid.Schema.Availability
migration.Schema.Availability = &change
}
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("availability migration validation error=%v, want %q", err, test.want)
}
})
}
if got := valid.displayKey(); !strings.Contains(got, CommandMigrationAvailability) {
t.Fatalf("availability display key = %q", got)
}
if got := matchCommandMigrationPhase(testSnapshot(testCommand("dws")), valid); got != commandMigrationPartial {
t.Fatalf("missing availability command phase = %q", got)
}
parent := before
parent.Commands = append(append([]Command(nil), before.Commands...), testCommand("dws devapp +event-list detail"))
if err := validateCommandMoveLegacyLeaves(parent, pending); err == nil || !strings.Contains(err.Error(), "must be a leaf") {
t.Fatalf("availability parent command error = %v", err)
}
moveSchema := commandMigrationManifest(CommandMigrationPending).Migrations[0].Schema
moveSchema.Availability = &CommandAvailabilityChange{Before: "available", After: "unavailable"}
if err := moveSchema.validate(CommandMigrationMove); err == nil || !strings.Contains(err.Error(), "must not declare schema availability") {
t.Fatalf("ordinary move availability error = %v", err)
}
}
func TestCrossPlatformCoverageCompatibilityVisibleAvailabilityMigrationLifecycle(t *testing.T) {
classicPending := availabilityCommandMigrationManifest(CommandMigrationPending)
steadyPending := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
steadyConsumed := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
visible := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
testCommand("dws devapp +event-list"),
)
hidden := testSnapshot(
testCommand("dws"),
testCommand("dws devapp"),
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
)
references := map[string]Snapshot{"merge-base": visible, "stable": visible}
if got, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("pending compatibility refinement authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("pending compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyConsumed); err != nil || len(got) != 1 {
t.Fatalf("consumed compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyConsumed); err != nil || len(got) != 1 {
t.Fatalf("retained consumed compatibility receipt authorizations=%#v error=%v", got, err)
}
if got, err := AuthorizeCommandMigrations(visible, references, empty, steadyPending); err != nil || len(got) != 0 {
t.Fatalf("candidate-added compatibility receipt authorizations=%#v error=%v", got, err)
}
modified := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
modified.Migrations[0].Reason = "Changed reason."
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, modified); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
t.Fatalf("modified compatibility refinement error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyConsumed); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
t.Fatalf("refinement and consumption in one change error=%v", err)
}
if _, err := AuthorizeCommandMigrations(hidden, references, steadyPending, steadyPending); err == nil || !strings.Contains(err.Error(), "drifted from compatibility-visible") {
t.Fatalf("hidden compatibility command error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, empty); err == nil || !strings.Contains(err.Error(), "must retain compatibility-visible") {
t.Fatalf("removed consumed compatibility receipt error=%v", err)
}
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyPending); err == nil || !strings.Contains(err.Error(), "back to pending") {
t.Fatalf("consumed compatibility receipt reverted error=%v", err)
}
}
func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
for _, test := range []struct {
name string
@@ -834,10 +686,7 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife
func cloneCommandMigration(source CommandMigration) CommandMigration {
cloned := source
if source.Schema.Parameters != nil {
cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters))
copy(cloned.Schema.Parameters, source.Schema.Parameters)
}
cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
for index, parameter := range source.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
@@ -854,34 +703,6 @@ func singleCommandMigrationManifest(state string) CommandMigrationManifest {
return manifest
}
func availabilityCommandMigrationManifest(state string) CommandMigrationManifest {
return CommandMigrationManifest{
Version: CommandMigrationManifestVersion,
Migrations: []CommandMigration{{
Kind: CommandMigrationAvailability,
Legacy: CommandMigrationSide{
Command: "dws devapp +event-list",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Schema: CommandMigrationSchema{
ProductID: "devapp",
SourceToolID: "devapp.shortcut_event_list",
Parameters: []CommandParameterMigration{},
Availability: &CommandAvailabilityChange{Before: "available", After: "unavailable"},
},
State: state,
Reason: "Fail closed until terminal pagination evidence is available.",
}},
}
}
func compatibilityVisibleAvailabilityCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := availabilityCommandMigrationManifest(state)
manifest.Migrations[0].Legacy.After = manifest.Migrations[0].Legacy.Before
return manifest
}
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
modified := source
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
-33
View File
@@ -71,13 +71,6 @@ func patConfigDir() string {
return filepath.Join(homeDir, ".dws")
}
// BrowserPolicyConfigDir returns the exact configuration directory used by
// the native PAT command. Shortcut adapters use this narrow seam so their
// write/readback cycle cannot drift to a different policy file.
func BrowserPolicyConfigDir() string {
return patConfigDir()
}
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
path := patPolicyPath(configDir)
data, err := patPolicyReadFile(path)
@@ -210,32 +203,6 @@ func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (Browse
}, nil
}
// ReadStoredBrowserPolicy reads the exact default or explicitly named agent
// entry without consulting the process agent-code environment. This is the
// verification companion to SetBrowserPolicy: callers can prove the same
// target was persisted rather than observing an environment-selected fallback.
func ReadStoredBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
if err != nil {
return BrowserPolicySelection{}, err
}
policy, err := LoadBrowserPolicy(configDir)
if err != nil {
return BrowserPolicySelection{}, err
}
if agentCode != "" {
entry, ok := policy.Agents[agentCode]
if !ok {
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy agent entry was not persisted")
}
return BrowserPolicySelection{Scope: "agent", AgentCode: agentCode, OpenBrowser: entry.OpenBrowser, Source: "agent"}, nil
}
if policy.Default == nil {
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy default entry was not persisted")
}
return BrowserPolicySelection{Scope: "default", OpenBrowser: policy.Default.OpenBrowser, Source: "default"}, nil
}
func newBrowserPolicyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "browser-policy",
-50
View File
@@ -105,9 +105,6 @@ func TestCrossPlatformCoverageBrowserPolicyInjectedEdges(t *testing.T) {
if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) {
t.Fatalf("read error = %v", err)
}
if _, err := ReadStoredBrowserPolicy("x", ""); !errors.Is(err, failure) {
t.Fatalf("readback error = %v", err)
}
patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil }
if _, err := LoadBrowserPolicy("x"); err == nil {
t.Fatal("invalid policy decoded")
@@ -182,53 +179,6 @@ func TestCrossPlatformCoverageBrowserPolicyCommandSetError(t *testing.T) {
}
}
func TestCrossPlatformCoverageBrowserPolicyReadbackAndSelectionEdges(t *testing.T) {
configDir := t.TempDir()
if got := BrowserPolicyConfigDir(); strings.TrimSpace(got) == "" {
t.Fatal("browser policy config directory is empty")
}
if _, err := ReadStoredBrowserPolicy(configDir, "bad code"); err == nil {
t.Fatal("invalid readback agent code succeeded")
}
if _, err := ReadStoredBrowserPolicy(configDir, "missing"); err == nil {
t.Fatal("missing agent readback succeeded")
}
if _, err := ReadStoredBrowserPolicy(configDir, ""); err == nil {
t.Fatal("missing default readback succeeded")
}
if _, err := SetBrowserPolicy(configDir, "agent", false); err != nil {
t.Fatal(err)
}
agent, err := ReadStoredBrowserPolicy(configDir, "agent")
if err != nil || agent.Scope != "agent" || agent.AgentCode != "agent" || agent.OpenBrowser {
t.Fatalf("agent readback = %#v, %v", agent, err)
}
resolvedAgent, err := ResolveBrowserPolicy(configDir, "agent")
if err != nil || resolvedAgent.Source != "agent" || resolvedAgent.OpenBrowser {
t.Fatalf("agent selection = %#v, %v", resolvedAgent, err)
}
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
t.Fatal(err)
}
defaultPolicy, err := ReadStoredBrowserPolicy(configDir, "")
if err != nil || defaultPolicy.Scope != "default" || !defaultPolicy.OpenBrowser {
t.Fatalf("default readback = %#v, %v", defaultPolicy, err)
}
resolvedDefault, err := ResolveBrowserPolicy(configDir, "unknown")
if err != nil || resolvedDefault.Source != "default" || !resolvedDefault.OpenBrowser {
t.Fatalf("default selection = %#v, %v", resolvedDefault, err)
}
empty, err := ResolveBrowserPolicy(t.TempDir(), "")
if err != nil || empty.Source == "" || !empty.OpenBrowser {
t.Fatalf("built-in selection = %#v, %v", empty, err)
}
cmd := newBrowserPolicyCommand()
if err := cmd.Execute(); err == nil {
t.Fatal("browser policy command accepted missing --enabled")
}
}
func TestCrossPlatformCoveragePATPureHelperEdges(t *testing.T) {
if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" {
t.Fatalf("product codes = %#v", got)
-13
View File
@@ -18,7 +18,6 @@ package pat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -92,17 +91,5 @@ DWS_CHANNEL 只用于上游 channelCode。`,
patCmd.AddCommand(newChmodCommand(c))
patCmd.AddCommand(newBrowserPolicyCommand())
// Shortcuts are mounted before this open-source PAT tree in the app. Fold
// that pre-existing service group into the native PAT parent so Cobra and
// Schema both see exactly one top-level path while preserving PAT's group
// behavior and adding every +leaf alongside chmod/browser-policy.
for _, existing := range root.Commands() {
if existing.Name() != patCmd.Name() {
continue
}
cobracmd.MergeCommandTree(patCmd, existing)
root.RemoveCommand(existing)
break
}
root.AddCommand(patCmd)
}
-38
View File
@@ -1,38 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package pat
import (
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"}
shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"})
root.AddCommand(&cobra.Command{Use: "aaa-other"})
root.AddCommand(shortcutGroup)
RegisterCommands(root, &fakeToolCaller{})
var patGroups []*cobra.Command
for _, command := range root.Commands() {
if command.Name() == "pat" {
patGroups = append(patGroups, command)
}
}
if len(patGroups) != 1 {
t.Fatalf("top-level PAT groups = %d, want 1", len(patGroups))
}
for _, child := range []string{"+fixture", "chmod", "browser-policy"} {
if found, _, err := patGroups[0].Find([]string{child}); err != nil || found == nil || found.Name() != child {
t.Errorf("merged PAT child %q missing: found=%v err=%v", child, found, err)
}
}
if patGroups[0].Short != "行为授权管理" {
t.Fatalf("native PAT group metadata was not preserved: %q", patGroups[0].Short)
}
}
-377
View File
@@ -1,377 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package aisearch implements strict, task-oriented Shortcut projections over
// DingTalk's enterprise search APIs.
package aisearch
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
)
const (
aisearchCompositeReason = "Reviewed AiSearch Shortcut adapter: the executable CLI owns strict success, collection, item, stable-identity, and output validation; the task contract is stronger than a direct MCP passthrough."
aisearchZeroGapReason = "Live exact-leaf probes with a random zero-match marker still returned non-empty business objects, so the Shortcut cannot prove query relevance or a legitimate zero result."
)
var aisearchReadSafety = contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
}
func aisearchResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中通过严格校验的命中数量"},"matches":{"type":"array","description":"通过 success、集合、元素和稳定身份校验的企业人员命中;该接口不发布分页或全局完整性承诺","items":{"type":"object","description":"带稳定身份且来源类型固定为 person 的企业人员命中","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"},"url":{"type":"string","minLength":1,"description":"稳定资源 URL"},"sourceType":{"type":"string","enum":["person"],"description":"已审核的企业人员来源类型"}},"required":["sourceType"],"anyOf":[{"required":["userId"]},{"required":["openDingTalkId"]},{"required":["url"]}],"additionalProperties":true}}},"required":["count","matches"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{
"matches.userId", "matches.openDingTalkId", "matches.url", "matches.name",
"matches.mobile", "matches.phone", "matches.jobNumber", "matches.email",
"matches.department", "matches.position", "matches.duty",
},
}
}
func aisearchContract(command, description, intent string, available bool, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
availability := contract.InterfaceAvailable
reason := aisearchCompositeReason
if !available {
availability = contract.InterfaceUnavailable
reason = aisearchZeroGapReason
}
decl := corecmd.ContractDecl{
Description: description,
Identity: contract.ToolIdentitySpec{
ProductID: "aisearch", Name: name, CanonicalPath: "aisearch." + name,
CLIPath: "aisearch " + command, PrimaryCLIPath: "aisearch " + command,
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{
"已有稳定资源 ID 时使用对应产品详情命令;不要把搜索命中当作资源写入回执",
"需要全局完结或可续页枚举时不要使用;下层未发布分页与终止证据",
},
Examples: examples,
},
Parameters: flags,
}
if available {
decl.Result = aisearchResult(description)
}
return decl
}
// SearchPerson is the only public AiSearch Shortcut. Exact live evidence
// proves both a known non-empty user fixture and an explicit zero hit in the
// phone dimension using a value longer than any valid phone number.
var SearchPerson = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "aisearch", Command: "+search-person", Product: "aisearch",
Description: "按姓名、部门、职责或组织关系语义搜索企业人员",
Intent: "只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。",
Risk: shortcut.RiskRead,
Safety: aisearchReadSafety,
Contract: aisearchContract(
"+search-person",
"按姓名、部门、职责或组织关系语义搜索企业人员",
"只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。",
true,
[]contract.ParamDecl{{Name: "query", Property: "keyword"}, {Name: "dimensions", Property: "dimension"}},
`dws aisearch +search-person --query "张三" --dimensions name --format json`,
`dws aisearch +search-person --query "产品" --dimensions duty,department --format json`,
),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "人员搜索关键词;--query 不能为空白"},
{Name: "dimensions", Type: shortcut.FlagStringSlice, Default: "all", Desc: "搜索维度;--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空白"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"dimensions"}, Description: "--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"},
},
Validate: validatePerson,
Execute: func(rt *shortcut.RuntimeContext) error {
return executeSearchForSource(rt, "enterprise_person_search", map[string]any{
"keyword": rt.Str("query"), "dimension": rt.StrSlice("dimensions"),
}, []string{"userId", "openDingTalkId", "url"}, "person")
},
}
// SearchEnterprise remains registered but unavailable. The IM slice can return
// stable identities for a known fixture, but a fresh random marker also
// returned a business object, so the service cannot prove a legitimate zero
// result or strict query relevance.
var SearchEnterprise = shortcut.Shortcut{
OutputRollout: output.RolloutLegacyOnly,
Service: "aisearch", Command: "+search-enterprise", Product: "aisearch",
Description: "按主题搜索企业知识、消息、邮件与协作内容",
Intent: "需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。",
Risk: shortcut.RiskRead,
Safety: aisearchReadSafety,
Contract: aisearchContract(
"+search-enterprise",
"按主题搜索企业知识、消息、邮件与协作内容",
"需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。",
false,
[]contract.ParamDecl{{Name: "queries"}, {Name: "types", Property: "searchTypes"}, {Name: "time-range", Property: "timeRange"}},
`dws aisearch +search-enterprise --queries "发布方案" --types im --format json`,
),
Flags: []shortcut.Flag{
{Name: "queries", Type: shortcut.FlagStringSlice, Required: true, Desc: "即时消息关键词列表"},
{Name: "types", Type: shortcut.FlagStringSlice, Default: "im", Desc: "已审核内容类型;--types 当前必须且只能为 im"},
{Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围,如今天、本周或过去一月"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom, Flags: []string{"types"},
Description: "--types 当前必须且只能为 im",
}},
Validate: validateEnterprise,
Execute: func(rt *shortcut.RuntimeContext) error {
return unavailableSearch("aisearch/search_enterprise")
},
}
// SearchBehavior remains registered but unavailable for the same zero-match
// relevance gap as SearchEnterprise.
var SearchBehavior = shortcut.Shortcut{
OutputRollout: output.RolloutLegacyOnly,
Service: "aisearch", Command: "+search-behavior", Product: "aisearch",
Description: "搜索发送、创建、分享、编辑或接收等企业行为记录",
Intent: "用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。",
Risk: shortcut.RiskRead,
Safety: aisearchReadSafety,
Contract: aisearchContract(
"+search-behavior",
"搜索发送、创建、分享、编辑或接收等企业行为记录",
"用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。",
false,
[]contract.ParamDecl{
{Name: "queries"}, {Name: "types", Property: "searchTypes"},
{Name: "behavior", Property: "behaviorType"}, {Name: "chat-scope", Property: "chatScope"},
{Name: "time-range", Property: "timeRange"}, {Name: "direction"},
},
`dws aisearch +search-behavior --types mail --behavior send --direction "我->同事" --format json`,
),
Flags: []shortcut.Flag{
{Name: "queries", Type: shortcut.FlagStringSlice, Desc: "内容关键词列表;汇总场景可留空"},
{Name: "types", Type: shortcut.FlagStringSlice, Default: "all", Desc: "内容类型:all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail"},
{Name: "behavior", Type: shortcut.FlagString, Default: "all", Enum: []string{"all", "send", "create", "share", "edit", "receive"}, Desc: "行为类型"},
{Name: "chat-scope", Type: shortcut.FlagString, Desc: "仅 IM 搜索时的会话或群范围"},
{Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围"},
{Name: "direction", Type: shortcut.FlagString, Desc: "交互方向,如我->同事"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom, Flags: []string{"types"},
Description: "--types 只能包含已审核内容类型,all 不能与其他类型并用;--chat-scope 仅能与 im 类型组合",
}},
Validate: validateBehavior,
Execute: func(rt *shortcut.RuntimeContext) error {
return unavailableSearch("aisearch/search_enterprise_behavior")
},
}
func unavailableSearch(operation string) error {
return responsecheck.Error(operation, "capability_unavailable", aisearchZeroGapReason)
}
func validatePerson(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("query")) == "" {
return responsecheck.Error("aisearch/enterprise_person_search", "empty_query", "--query 不能为空白")
}
return validateValues("--dimensions", rt.StrSlice("dimensions"), map[string]bool{
"all": true, "name": true, "department": true, "position": true, "duty": true,
"supervisor": true, "subordinate": true, "phone": true, "jobNumber": true,
})
}
func validateEnterprise(rt *shortcut.RuntimeContext) error {
queries := rt.StrSlice("queries")
if len(queries) == 0 {
return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 至少需要一个非空即时消息关键词")
}
for _, query := range queries {
if strings.TrimSpace(query) == "" {
return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 不能包含空关键词")
}
}
types := rt.StrSlice("types")
if len(types) != 1 || strings.TrimSpace(types[0]) != "im" {
return responsecheck.Error("aisearch/search_enterprise", "unsupported_search_type", "--types 当前必须且只能为 im;其他类型请使用 aisearch enterprise 原子命令")
}
return nil
}
func validateBehavior(rt *shortcut.RuntimeContext) error {
types := rt.StrSlice("types")
if err := validateSearchTypes(types); err != nil {
return err
}
if rt.Str("chat-scope") != "" && !contains(types, "im") {
return responsecheck.Error("aisearch/search_enterprise_behavior", "invalid_chat_scope", "--chat-scope 仅能与 --types im 组合")
}
return nil
}
func validateSearchTypes(values []string) error {
return validateValues("--types", values, map[string]bool{
"all": true, "document": true, "im": true, "calendar": true, "todo": true,
"minute": true, "report": true, "image": true, "link": true, "notable": true,
"baike": true, "mail": true,
})
}
func validateValues(flag string, values []string, allowed map[string]bool) error {
if len(values) == 0 {
return responsecheck.Error("aisearch/validate", "empty_selector", flag+" 不能为空")
}
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || !allowed[value] {
return responsecheck.Error("aisearch/validate", "invalid_selector", fmt.Sprintf("%s 包含未支持值 %q", flag, value))
}
if seen[value] {
return responsecheck.Error("aisearch/validate", "duplicate_selector", fmt.Sprintf("%s 包含重复值 %q", flag, value))
}
seen[value] = true
}
if len(values) > 1 && seen["all"] {
return responsecheck.Error("aisearch/validate", "conflicting_selector", flag+" 中 all 不能与其他值并用")
}
return nil
}
func contains(values []string, expected string) bool {
for _, value := range values {
if strings.TrimSpace(value) == expected {
return true
}
}
return false
}
func executeSearchForSource(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys []string, expectedSource string) error {
operation := "aisearch/" + tool
data, err := rt.CallMCPReadData("aisearch", tool, params)
if err != nil {
return err
}
matches, err := projectSearch(data, operation, identityKeys)
if err != nil {
return err
}
for index, item := range matches {
if source := firstNonEmptyString(item, "sourceType"); source != expectedSource {
return responsecheck.Error(operation, "unexpected_item_source", fmt.Sprintf("响应 result[%d] 来源 %q 与已审核类型 %q 不一致", index, source, expectedSource))
}
}
return rt.Output(map[string]any{"count": len(matches), "matches": matches})
}
func projectSearch(data map[string]any, operation string, identityKeys []string) ([]map[string]any, error) {
if hasConflictingError(data) {
return nil, responsecheck.Error(operation, "conflicting_error_fields", "响应同时声明 success=true 和非空错误字段")
}
items, err := responsecheck.RequireObjectCollection(data, operation, "result")
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(items))
seen := make(map[string]bool, len(items))
for index, item := range items {
projected := make(map[string]any, len(item))
for key, value := range item {
projected[key] = value
}
source, ok := projected["sourceType"].(string)
source = strings.TrimSpace(source)
if !ok || source == "" {
return nil, responsecheck.Error(operation, "missing_item_source", fmt.Sprintf("响应 result[%d] 缺少非空 sourceType", index))
}
projected["sourceType"] = source
candidateKeys := append([]string(nil), identityKeys...)
for _, key := range []string{"userId", "openDingTalkId", "url", "openConversationId"} {
if !contains(candidateKeys, key) {
candidateKeys = append(candidateKeys, key)
}
}
for _, key := range candidateKeys {
raw, present := projected[key]
if !present || raw == nil {
delete(projected, key)
continue
}
value, valid := raw.(string)
if !valid {
return nil, responsecheck.Error(operation, "malformed_item_identity", fmt.Sprintf("响应 result[%d].%s 必须是字符串", index, key))
}
value = strings.TrimSpace(value)
if value == "" {
delete(projected, key)
continue
}
projected[key] = value
}
identity := firstNonEmptyString(projected, identityKeys...)
if identity == "" {
return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("响应 result[%d] 缺少稳定身份", index))
}
if seen[identity] {
return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("响应 result[%d] 重复稳定身份", index))
}
seen[identity] = true
out = append(out, projected)
}
return out, nil
}
func hasConflictingError(data map[string]any) bool {
envelope := data
if content, ok := data["content"].(map[string]any); ok {
envelope = content
}
success, ok := envelope["success"].(bool)
if !ok || !success {
return false
}
if message := firstNonEmptyString(envelope, "errorMsg", "errorMessage"); message != "" {
return true
}
if code, present := envelope["errorCode"]; present && code != nil {
switch typed := code.(type) {
case string:
return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0"
case float64:
return typed != 0
default:
return true
}
}
return false
}
func firstNonEmptyString(object map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func init() {
shortcut.Register(SearchPerson, SearchEnterprise, SearchBehavior)
}
@@ -1,369 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package aisearch
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type aisearchCaller struct {
product string
tool string
args map[string]any
payload string
err error
calls int
}
func (caller *aisearchCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
caller.product, caller.tool, caller.args = product, tool, args
caller.calls++
if caller.err != nil {
return nil, caller.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.payload}}}, nil
}
func TestCrossPlatformCoverageAiSearchValidationAndExecutionBranches(t *testing.T) {
for _, test := range []struct {
values []string
wantErr bool
}{
{nil, true},
{[]string{"unknown"}, true},
{[]string{"name", "name"}, true},
{[]string{"all", "name"}, true},
{[]string{"name", "duty"}, false},
} {
if err := validateValues("--dimensions", test.values, map[string]bool{"all": true, "name": true, "duty": true}); (err != nil) != test.wantErr {
t.Errorf("validateValues(%v) err=%v wantErr=%v", test.values, err, test.wantErr)
}
}
enterprise := func(queries, types []string) error {
cmd := &cobra.Command{Use: "+search-enterprise"}
cmd.Flags().StringSlice("queries", queries, "")
cmd.Flags().StringSlice("types", types, "")
return validateEnterprise(shortcut.RuntimeContextForTest(cmd, SearchEnterprise))
}
if enterprise(nil, []string{"im"}) == nil || enterprise([]string{" "}, []string{"im"}) == nil || enterprise([]string{"fixture"}, []string{"document"}) == nil || enterprise([]string{"fixture"}, []string{"im"}) != nil {
t.Fatal("enterprise validation branch matrix drift")
}
behavior := &cobra.Command{Use: "+search-behavior"}
behavior.Flags().StringSlice("types", []string{"im"}, "")
behavior.Flags().String("chat-scope", "fixture", "")
if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err != nil {
t.Fatalf("valid IM behavior selector: %v", err)
}
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
helpers.InitDepsForTest(t, caller)
declaration := SearchPerson
declaration.OutputRollout = output.RolloutLegacyOnly
command := &cobra.Command{Use: "+search-person"}
command.Flags().String("query", "fixture", "")
command.Flags().StringSlice("dimensions", []string{"name"}, "")
runtime := shortcut.RuntimeContextForTest(command, declaration)
caller.err = errors.New("transport")
if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil {
t.Fatal("transport failure returned success")
}
caller.err = nil
caller.payload = `{"success":true}`
if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil {
t.Fatal("projection failure returned success")
}
caller.err = errors.New("transport")
if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil {
t.Fatal("source transport failure returned success")
}
caller.err = nil
caller.payload = `{"success":true}`
if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil {
t.Fatal("source projection failure returned success")
}
}
func TestCrossPlatformCoverageAiSearchConflictingEvidenceAndIdentityNormalization(t *testing.T) {
for index, data := range []map[string]any{
{"content": map[string]any{"success": true, "errorCode": float64(1), "result": []any{}}},
{"success": true, "errorCode": map[string]any{}, "result": []any{}},
} {
if !hasConflictingError(data) {
t.Errorf("conflicting evidence %d not detected", index)
}
}
for index, data := range []map[string]any{
{"success": false, "errorCode": "FAILED"},
{"success": true, "errorCode": float64(0)},
{"success": true, "errorCode": "0"},
} {
if hasConflictingError(data) {
t.Errorf("non-conflicting evidence %d rejected", index)
}
}
items, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{
"sourceType": " person ", "userId": " stable ", "openDingTalkId": " ", "url": nil,
}}}, "aisearch/test", []string{"userId"})
if err != nil || len(items) != 1 || items[0]["sourceType"] != "person" || items[0]["userId"] != "stable" {
t.Fatalf("identity normalization failed: items=%v err=%v", items, err)
}
if _, present := items[0]["openDingTalkId"]; present {
t.Fatal("blank optional identity was not omitted")
}
if _, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{"sourceType": " ", "userId": "stable"}}}, "aisearch/test", []string{"userId"}); err == nil {
t.Fatal("blank sourceType returned success")
}
}
func (*aisearchCaller) Format() string { return "json" }
func (*aisearchCaller) DryRun() bool { return false }
func (*aisearchCaller) Fields() string { return "" }
func (*aisearchCaller) JQ() string { return "" }
func TestCrossPlatformCoverageAiSearchRejectsFalseSuccessAndBadCollections(t *testing.T) {
for identity, value := range map[string]string{
"userId": "user-fixture", "openDingTalkId": "open-fixture", "url": "https://example.invalid/user-fixture",
} {
valid := map[string]any{"success": true, "result": []any{map[string]any{"sourceType": "person", identity: value}}}
items, err := projectSearch(valid, "aisearch/test", []string{"userId", "openDingTalkId", "url"})
if err != nil || len(items) != 1 || items[0][identity] != value {
t.Fatalf("valid %s identity rejected: items=%v err=%v", identity, items, err)
}
}
explicitEmpty := map[string]any{"success": true, "result": []any{}}
items, err := projectSearch(explicitEmpty, "aisearch/test", []string{"userId"})
if err != nil || len(items) != 0 {
t.Fatalf("explicit empty rejected: items=%v err=%v", items, err)
}
broken := []map[string]any{
{},
{"result": []any{}},
{"success": "true", "result": []any{}},
{"success": false, "result": []any{}},
{"success": true},
{"success": true, "result": map[string]any{}},
{"success": true, "result": []any{"bad"}},
{"success": true, "result": []any{map[string]any{}}},
{"success": true, "result": []any{map[string]any{"sourceType": "person"}}},
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": 1}}},
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "same"}, map[string]any{"sourceType": "person", "userId": "same"}}},
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "good", "openDingTalkId": map[string]any{}}}},
{"success": true, "errorCode": "REMOTE_ERROR", "result": []any{}},
{"success": true, "errorMsg": "conflict", "result": []any{}},
}
for index, data := range broken {
if got, projectErr := projectSearch(data, "aisearch/test", []string{"userId"}); projectErr == nil {
t.Errorf("broken response %d returned success: %v", index, got)
}
}
}
func TestCrossPlatformCoverageAiSearchSelectorsFailBeforeRemoteCall(t *testing.T) {
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "+search-person"}
cmd.Flags().StringSlice("dimensions", []string{"all", "name"}, "")
if err := validatePerson(shortcut.RuntimeContextForTest(cmd, SearchPerson)); err == nil {
t.Fatal("all plus another dimension must fail")
}
if caller.calls != 0 {
t.Fatalf("invalid selector made %d remote calls", caller.calls)
}
blank := &cobra.Command{Use: "+search-person"}
blank.Flags().String("query", " ", "")
blank.Flags().StringSlice("dimensions", []string{"name"}, "")
if err := validatePerson(shortcut.RuntimeContextForTest(blank, SearchPerson)); err == nil {
t.Fatal("blank person query must fail")
}
if caller.calls != 0 {
t.Fatalf("blank person query made %d remote calls", caller.calls)
}
behavior := &cobra.Command{Use: "+search-behavior"}
behavior.Flags().StringSlice("types", []string{"document"}, "")
behavior.Flags().String("chat-scope", "fixture-chat", "")
if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err == nil {
t.Fatal("chat scope without im must fail")
}
if caller.calls != 0 {
t.Fatalf("invalid behavior selector made %d remote calls", caller.calls)
}
enterprise := &cobra.Command{Use: "+search-enterprise"}
enterprise.Flags().StringSlice("queries", []string{""}, "")
enterprise.Flags().StringSlice("types", []string{"im"}, "")
if err := validateEnterprise(shortcut.RuntimeContextForTest(enterprise, SearchEnterprise)); err == nil {
t.Fatal("empty enterprise query must fail")
}
if caller.calls != 0 {
t.Fatalf("empty enterprise query made %d remote calls", caller.calls)
}
}
func TestCrossPlatformCoverageUnavailableAiSearchMakesNoRemoteCall(t *testing.T) {
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
helpers.InitDepsForTest(t, caller)
for _, declaration := range []shortcut.Shortcut{SearchEnterprise, SearchBehavior} {
if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil || !strings.Contains(err.Error(), "cannot prove query relevance") {
t.Errorf("%s unavailable error = %v", declaration.Command, err)
}
}
if caller.calls != 0 {
t.Fatalf("unavailable searches made %d remote calls", caller.calls)
}
}
func TestCrossPlatformCoverageAiSearchExactShortcutMapping(t *testing.T) {
caller := &aisearchCaller{payload: `{"success":true,"errorCode":null,"errorMsg":"","result":[{"sourceType":"person","userId":"stable-user"}]}`}
helpers.InitDepsForTest(t, caller)
declaration := SearchPerson
declaration.OutputRollout = output.RolloutLegacyOnly
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "aisearch"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"aisearch", "+search-person", "--query", "fixture", "--dimensions", "name,duty"})
if err := root.Execute(); err != nil {
t.Fatalf("exact shortcut execution: %v", err)
}
if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "enterprise_person_search" {
t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool)
}
if caller.args["keyword"] != "fixture" {
t.Fatalf("keyword = %#v", caller.args["keyword"])
}
dimensions, ok := caller.args["dimension"].([]string)
if !ok || strings.Join(dimensions, ",") != "name,duty" {
t.Fatalf("dimensions = %#v", caller.args["dimension"])
}
caller.calls = 0
caller.payload = `{"success":true,"result":[{"sourceType":"im","url":"https://example.invalid/non-person-resource"}]}`
driftCommand := &cobra.Command{Use: "+search-person"}
driftCommand.Flags().String("query", "fixture", "")
driftCommand.Flags().StringSlice("dimensions", []string{"name"}, "")
if err := declaration.Execute(shortcut.RuntimeContextForTest(driftCommand, declaration)); err == nil || !strings.Contains(err.Error(), "来源") {
t.Fatalf("non-person source was not rejected: %v", err)
}
if caller.calls != 1 || caller.tool != "enterprise_person_search" {
t.Fatalf("source drift call = count:%d tool:%q", caller.calls, caller.tool)
}
}
func TestCrossPlatformCoverageAiSearchEnterpriseHardenedMapperSourceGuard(t *testing.T) {
caller := &aisearchCaller{payload: `{"success":true,"result":[{"sourceType":"im","openConversationId":"cid-fixture"}]}`}
helpers.InitDepsForTest(t, caller)
declaration := SearchEnterprise
declaration.OutputRollout = output.RolloutLegacyOnly
command := &cobra.Command{Use: "+search-enterprise"}
command.Flags().StringSlice("queries", []string{"fixture"}, "")
command.Flags().StringSlice("types", []string{"im"}, "")
command.Flags().String("time-range", "过去一周", "")
runtime := shortcut.RuntimeContextForTest(command, declaration)
if err := executeSearchForSource(runtime, "search_enterprise", map[string]any{
"queries": []string{"fixture"}, "searchTypes": []string{"im"}, "timeRange": "过去一周",
}, []string{"openConversationId", "url"}, "im"); err != nil {
t.Fatalf("hardened enterprise mapper: %v", err)
}
if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "search_enterprise" {
t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool)
}
if got := caller.args["searchTypes"]; len(got.([]string)) != 1 || got.([]string)[0] != "im" {
t.Fatalf("searchTypes = %#v", got)
}
if caller.args["timeRange"] != "过去一周" {
t.Fatalf("timeRange = %#v", caller.args["timeRange"])
}
caller.payload = `{"success":true,"result":[{"sourceType":"minute","openConversationId":"cid-fixture"}]}`
badCommand := &cobra.Command{Use: "+search-enterprise"}
badCommand.Flags().StringSlice("queries", []string{"fixture"}, "")
badCommand.Flags().StringSlice("types", []string{"im"}, "")
badCommand.Flags().String("time-range", "", "")
if err := executeSearchForSource(
shortcut.RuntimeContextForTest(badCommand, declaration),
"search_enterprise",
map[string]any{"queries": []string{"fixture"}, "searchTypes": []string{"im"}},
[]string{"openConversationId", "url"},
"im",
); err == nil || !strings.Contains(err.Error(), "来源") {
t.Fatalf("source drift error = %v", err)
}
}
func TestCrossPlatformCoverageAiSearchCatalogAndContracts(t *testing.T) {
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "aisearch" {
registered[item.Command] = item
}
}
if len(registered) != 3 {
t.Fatalf("registered AiSearch shortcuts = %d, want 3", len(registered))
}
for _, command := range []string{"+search-person", "+search-enterprise", "+search-behavior"} {
item := registered[command]
if item.Command == "" {
t.Fatalf("missing %s", command)
}
if strings.TrimSpace(item.Safety.Effect) == "" {
t.Errorf("%s lacks Safety", command)
}
}
public := registered["+search-person"]
if public.OutputRollout != output.RolloutUnifiedActive || public.Contract.Result == nil || len(public.Contract.Result.SensitivePaths) == 0 {
t.Fatalf("search-person lacks unified typed sensitive Result: %#v", public)
}
var schema map[string]any
if err := json.Unmarshal(public.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" {
t.Fatalf("search-person result schema invalid: schema=%v err=%v", schema, err)
}
matches := schema["properties"].(map[string]any)["matches"].(map[string]any)
items := matches["items"].(map[string]any)
sourceType := items["properties"].(map[string]any)["sourceType"].(map[string]any)
if got, ok := sourceType["enum"].([]any); !ok || len(got) != 1 || got[0] != "person" {
t.Fatalf("search-person sourceType enum = %#v", sourceType["enum"])
}
identityAlternatives, ok := items["anyOf"].([]any)
if !ok || len(identityAlternatives) != 3 {
t.Fatalf("search-person stable identity anyOf = %#v", items["anyOf"])
}
for index, identity := range []string{"userId", "openDingTalkId", "url"} {
branch, branchOK := identityAlternatives[index].(map[string]any)
required, requiredOK := branch["required"].([]any)
if !branchOK || !requiredOK || len(required) != 1 || required[0] != identity {
t.Fatalf("search-person stable identity anyOf[%d] = %#v, want required %q", index, identityAlternatives[index], identity)
}
}
if registered["+search-person"].Hidden || registered["+search-person"].Availability != shortcut.AvailabilityAvailable {
t.Fatalf("search-person visibility/availability = hidden:%v availability:%q", registered["+search-person"].Hidden, registered["+search-person"].Availability)
}
for _, command := range []string{"+search-enterprise", "+search-behavior"} {
item := registered[command]
if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
t.Errorf("%s must remain hidden/unavailable without Result: hidden=%v availability=%q rollout=%q result=%v", command, item.Hidden, item.Availability, item.OutputRollout, item.Contract.Result)
}
}
}
-14
View File
@@ -24,7 +24,6 @@ package aitable
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -60,19 +59,6 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
return m, nil
}
// trimNonEmpty returns a copy of ss with each element trimmed of surrounding
// whitespace and all empty entries dropped. Used to sanitize string-slice
// flags (e.g. --field-ids) before they reach the downstream MCP tool.
func trimNonEmpty(ss []string) []string {
out := ss[:0]
for _, s := range ss {
if t := strings.TrimSpace(s); t != "" {
out = append(out, t)
}
}
return out
}
// resolveNamedList finds a list in a response envelope without conflating a
// missing/malformed collection with a legitimate empty array. Some AITable MCP
// versions use a direct collection key while others wrap it one level below
-534
View File
@@ -1,534 +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.
// This file declares the 7 datasource shortcuts for the aitable service:
// create / update / sync / sync-status / get-config / list-sources /
// get-fields. Each maps 1:1 onto a datasource MCP tool on the "aitable"
// server. datasource-type is passed through without CLI-side enum
// validation; source-config is validated as a JSON object but passed
// through as a raw string (MCP types it as string).
package aitable
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// ─────────────────────────────────────────────────────────────
// datasource: 数据源同步管理(server: aitable)
// ─────────────────────────────────────────────────────────────
// DatasourceCreate 为指定 AI 表格创建数据源同步配置(create_datasource)。
var DatasourceCreate = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-create",
Product: serverMain,
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
Intent: "当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_create",
CanonicalPath: "aitable.shortcut_datasource_create",
CLIPath: "aitable +datasource-create",
PrimaryCLIPath: "aitable +datasource-create",
},
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
UseWhen: []string{"当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。"},
AvoidWhen: []string{
"目标 Base 已有数据源表且仅需更新配置时(改用 +datasource-update)",
"仅需触发已有数据源表的同步时(改用 +datasource-sync)",
},
Examples: []string{
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID(通过 +base-list / +base-search 获取)", Required: true},
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(是否保留已删除字段,默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入", Required: true},
{Name: "auto", Type: shortcut.FlagBool, Desc: "是否开启自动同步,默认 false;创建新数据源表时该字段始终下发给下游"},
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)。不传时使用下游默认自动同步策略"},
},
Tips: []string{
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
return err
}
params := map[string]any{
"baseId": rt.Str("base-id"),
"datasourceType": rt.Str("datasource-type"),
"sourceConfig": rt.Str("source-config"),
}
params["auto"] = rt.Bool("auto")
if rt.Changed("field-ids") {
raw := rt.StrSlice("field-ids")
cleaned := trimNonEmpty(raw)
if len(cleaned) == 0 {
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
}
params["fieldIds"] = cleaned
}
if rt.Changed("auto-sync-setting") {
v := rt.Str("auto-sync-setting")
if v == "" {
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
}
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
return err
}
params["autoSyncSetting"] = v
}
data, err := rt.CallMCPData(serverMain, "create_datasource", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceUpdate 更新已有数据源表的同步配置(update_datasource_config)。
var DatasourceUpdate = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-update",
Product: serverMain,
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
Intent: "当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_update",
CanonicalPath: "aitable.shortcut_datasource_update",
CLIPath: "aitable +datasource-update",
PrimaryCLIPath: "aitable +datasource-update",
},
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
UseWhen: []string{"当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。"},
AvoidWhen: []string{
"需要创建新数据源表时(改用 +datasource-create)",
"仅需触发同步不改配置时(改用 +datasource-sync)",
},
Examples: []string{
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "已存在的数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的数据源表)", Required: true},
{Name: "source-config", Type: shortcut.FlagString, Desc: "可选。新的源配置 JSON 字符串。不传时保持原有配置不变;传入时整体覆盖。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入"},
{Name: "auto", Type: shortcut.FlagBool, Desc: "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变"},
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)"},
},
Tips: []string{
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
}
if rt.Changed("source-config") {
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
return err
}
params["sourceConfig"] = rt.Str("source-config")
}
if rt.Changed("auto") {
params["auto"] = rt.Bool("auto")
}
if rt.Changed("field-ids") {
raw := rt.StrSlice("field-ids")
cleaned := trimNonEmpty(raw)
if len(cleaned) == 0 {
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
}
params["fieldIds"] = cleaned
}
if rt.Changed("auto-sync-setting") {
v := rt.Str("auto-sync-setting")
if v == "" {
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
}
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
return err
}
params["autoSyncSetting"] = v
}
if !rt.Changed("source-config") && !rt.Changed("auto") && !rt.Changed("field-ids") && !rt.Changed("auto-sync-setting") {
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 +datasource-sync")
}
data, err := rt.CallMCPData(serverMain, "update_datasource_config", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceSync 对数据源表触发一次手动同步(run_datasource_sync)。
var DatasourceSync = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-sync",
Product: serverMain,
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
Intent: "当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_sync",
CanonicalPath: "aitable.shortcut_datasource_sync",
CLIPath: "aitable +datasource-sync",
PrimaryCLIPath: "aitable +datasource-sync",
},
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
UseWhen: []string{"当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。"},
AvoidWhen: []string{
"需要创建新数据源表时(改用 +datasource-create)",
"需要更新配置时(改用 +datasource-update)",
},
Examples: []string{
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "table-ids", Type: shortcut.FlagStringSlice, Desc: "待触发同步的数据源表 ID 列表(通过 +base-get / +table-list 获取,仅允许 sync=true 的表,1-5 个)", Required: true},
},
Tips: []string{
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
rawTableIDs := rt.StrSlice("table-ids")
tableIDs := trimNonEmpty(rawTableIDs)
if len(tableIDs) < 1 || len(tableIDs) > 5 {
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
}
params := map[string]any{
"baseId": rt.Str("base-id"),
"tableIds": tableIDs,
}
data, err := rt.CallMCPData(serverMain, "run_datasource_sync", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceSyncStatus 查询数据源表同步任务状态(get_datasource_sync_status)。
var DatasourceSyncStatus = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-sync-status",
Product: serverMain,
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
Intent: "当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_sync_status",
CanonicalPath: "aitable.shortcut_datasource_sync_status",
CLIPath: "aitable +datasource-sync-status",
PrimaryCLIPath: "aitable +datasource-sync-status",
},
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
UseWhen: []string{"当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。"},
AvoidWhen: []string{
"需要触发同步时(改用 +datasource-sync)",
},
Examples: []string{
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
{Name: "task-ids", Type: shortcut.FlagStringSlice, Desc: "待查询的同步任务 ID 列表(由 +datasource-sync / +datasource-create / +datasource-update 返回)。单次最多 5 个,超出请拆分多次调用。", Required: true},
},
Tips: []string{
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
}
rawTaskIDs := rt.StrSlice("task-ids")
taskIDs := trimNonEmpty(rawTaskIDs)
if len(taskIDs) < 1 || len(taskIDs) > 5 {
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(taskIDs))
}
params["taskIds"] = taskIDs
data, err := rt.CallMCPData(serverMain, "get_datasource_sync_status", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceGetConfig 获取数据源表的同步配置信息(get_datasource_config)。
var DatasourceGetConfig = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-get-config",
Product: serverMain,
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
Intent: "当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_get_config",
CanonicalPath: "aitable.shortcut_datasource_get_config",
CLIPath: "aitable +datasource-get-config",
PrimaryCLIPath: "aitable +datasource-get-config",
},
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
UseWhen: []string{"当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。"},
AvoidWhen: []string{
"需要更新配置时(改用 +datasource-update)",
"需要查询同步任务状态时(改用 +datasource-sync-status)",
},
Examples: []string{
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
},
Tips: []string{
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
}
data, err := rt.CallMCPData(serverMain, "get_datasource_config", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceListSources 列出指定数据源类型可用的来源(list_datasource_sources)。
var DatasourceListSources = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-list-sources",
Product: serverMain,
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
Intent: "当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_list_sources",
CanonicalPath: "aitable.shortcut_datasource_list_sources",
CLIPath: "aitable +datasource-list-sources",
PrimaryCLIPath: "aitable +datasource-list-sources",
},
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
UseWhen: []string{"当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。"},
AvoidWhen: []string{
"需要创建数据源表时(改用 +datasource-create)",
"需要获取数据源字段结构时(改用 +datasource-get-fields)",
},
Examples: []string{
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
},
Tips: []string{
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
"datasourceType": rt.Str("datasource-type"),
}
data, err := rt.CallMCPData(serverMain, "list_datasource_sources", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
// DatasourceGetFields 获取指定数据源来源的可同步字段列表(get_datasource_fields)。
var DatasourceGetFields = shortcut.Shortcut{
Service: "aitable",
Command: "+datasource-get-fields",
Product: serverMain,
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
Intent: "当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_datasource_get_fields",
CanonicalPath: "aitable.shortcut_datasource_get_fields",
CLIPath: "aitable +datasource-get-fields",
PrimaryCLIPath: "aitable +datasource-get-fields",
},
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
UseWhen: []string{"当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。"},
AvoidWhen: []string{
"需要列出可用来源时(改用 +datasource-list-sources)",
"需要创建数据源表时(改用 +datasource-create)",
},
Examples: []string{
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。结构同 +datasource-create 的 --source-config,需含 processCode、name、iconUrl、url、dataType 及对应时间字段", Required: true},
},
Tips: []string{
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
return err
}
params := map[string]any{
"baseId": rt.Str("base-id"),
"datasourceType": rt.Str("datasource-type"),
"sourceConfig": rt.Str("source-config"),
}
data, err := rt.CallMCPData(serverMain, "get_datasource_fields", params)
if err != nil {
return err
}
return rt.Output(data)
},
}
func init() {
shortcut.Register(withReviewedAITableShortcutContracts(
DatasourceCreate,
DatasourceUpdate,
DatasourceSync,
DatasourceSyncStatus,
DatasourceGetConfig,
DatasourceListSources,
DatasourceGetFields,
)...)
}
@@ -1,367 +0,0 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type datasourceCoverageCaller struct {
err error
resp string
argLog []map[string]any
}
func (c *datasourceCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
c.argLog = append(c.argLog, args)
if c.err != nil {
return nil, c.err
}
text := c.resp
if text == "" {
text = `{"status":"success","data":{}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *datasourceCoverageCaller) Format() string { return "json" }
func (c *datasourceCoverageCaller) DryRun() bool { return false }
func (c *datasourceCoverageCaller) Fields() string { return "" }
func (c *datasourceCoverageCaller) JQ() string { return "" }
func runDatasourceShortcutCLI(t *testing.T, caller *datasourceCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"aitable"}, args...))
return root.Execute()
}
// ── DatasourceCreate error paths ─────────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", "not-json")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config parse error", err)
}
}
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
"--auto-sync-setting", "not-json")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
}
}
func TestCrossPlatformCoverageDatasourceCreatePropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyFieldIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceCreateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
"--field-ids", " , , ")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyAutoSyncSetting(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceCreatePassesFieldIDsThrough(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
"--field-ids", "fld1,fld2")
if err != nil {
t.Fatalf("create with valid --field-ids should succeed: %v", err)
}
if len(caller.argLog) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
}
got, _ := caller.argLog[0]["fieldIds"].([]string)
if len(got) != 2 || got[0] != "fld1" || got[1] != "fld2" {
t.Fatalf("fieldIds = %#v, want [fld1 fld2]", got)
}
}
// ── DatasourceUpdate error paths ─────────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--source-config", "not-json")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config parse error", err)
}
}
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--auto-sync-setting", "not-json")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
}
}
func TestCrossPlatformCoverageDatasourceUpdateRejectsNoChanges(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1")
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
t.Fatalf("error = %v, want no-changes error", err)
}
}
func TestCrossPlatformCoverageDatasourceUpdatePropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", "--auto")
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyFieldIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceUpdateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--field-ids", " , ")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyAutoSyncSetting(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceUpdatePassesFieldIDsThrough(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
"--field-ids", "fldA,fldB,fldC")
if err != nil {
t.Fatalf("update with valid --field-ids should succeed: %v", err)
}
if len(caller.argLog) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
}
got, _ := caller.argLog[0]["fieldIds"].([]string)
if len(got) != 3 || got[0] != "fldA" || got[1] != "fldB" || got[2] != "fldC" {
t.Fatalf("fieldIds = %#v, want [fldA fldB fldC]", got)
}
}
// ── DatasourceSync error paths ────────────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceSyncShortcutRejectsTooManyTableIDs(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestCrossPlatformCoverageDatasourceSyncShortcutPropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "TBL1")
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
// ── DatasourceSyncStatus error paths ─────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutRejectsTooManyTaskIDs(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
"--task-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutPropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
"--task-ids", "TASK1")
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
// ── DatasourceSync whitespace trimming ──────────────────────────────────────
func TestCrossPlatformCoverageDatasourceSyncRejectsWhitespaceOnlyTableIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " , ")
if err == nil || (!strings.Contains(err.Error(), "table-ids") && !strings.Contains(err.Error(), "1-5")) {
t.Fatalf("error = %v, want table-ids rejection", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when whitespace-only table-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceSyncTrimsWhitespaceFromTableIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " TBL1 , TBL2 ")
if err != nil {
t.Fatalf("sync with whitespace-padded --table-ids should succeed: %v", err)
}
if len(caller.argLog) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
}
got, _ := caller.argLog[0]["tableIds"].([]string)
if len(got) != 2 || got[0] != "TBL1" || got[1] != "TBL2" {
t.Fatalf("tableIds = %#v, want [TBL1 TBL2]", got)
}
}
// ── DatasourceSyncStatus whitespace trimming ───────────────────────────────
func TestCrossPlatformCoverageDatasourceSyncStatusRejectsWhitespaceOnlyTaskIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
"--task-ids", " , ")
if err == nil || (!strings.Contains(err.Error(), "task-ids") && !strings.Contains(err.Error(), "1-5")) {
t.Fatalf("error = %v, want task-ids rejection", err)
}
if len(caller.argLog) != 0 {
t.Fatalf("MCP should not be called when whitespace-only task-ids is rejected, got %d calls", len(caller.argLog))
}
}
func TestCrossPlatformCoverageDatasourceSyncStatusTrimsWhitespaceFromTaskIDs(t *testing.T) {
caller := &datasourceCoverageCaller{}
err := runDatasourceShortcutCLI(t, caller,
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
"--task-ids", " TASK1 , TASK2 ")
if err != nil {
t.Fatalf("sync-status with whitespace-padded --task-ids should succeed: %v", err)
}
if len(caller.argLog) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
}
got, _ := caller.argLog[0]["taskIds"].([]string)
if len(got) != 2 || got[0] != "TASK1" || got[1] != "TASK2" {
t.Fatalf("taskIds = %#v, want [TASK1 TASK2]", got)
}
}
// ── DatasourceGetConfig error path ───────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceGetConfigShortcutPropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-get-config", "--base-id", "BASE1", "--table-id", "TBL1")
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
// ── DatasourceListSources error path ─────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceListSourcesShortcutPropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-list-sources", "--base-id", "BASE1", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
// ── DatasourceGetFields error paths ──────────────────────────────────────────
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutRejectsInvalidSourceConfig(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", "not-json")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config parse error", err)
}
}
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutPropagatesCallMCPError(t *testing.T) {
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
t.Fatalf("error = %v, want mcp-error", err)
}
}
@@ -1,69 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageAiSearchSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_aisearch.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "aisearch" {
t.Fatalf("service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "aisearch" {
registered[item.Command] = item
}
}
if len(registered) != 3 || len(source.Shortcuts) != 3 {
t.Fatalf("registered/catalog = %d/%d, want 3/3", len(registered), len(source.Shortcuts))
}
public, unavailable := 0, 0
for command, record := range source.Shortcuts {
item, ok := registered[command]
if !ok {
t.Errorf("catalog contains stale %s", command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
t.Errorf("%s semantic facts drifted", command)
}
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" {
t.Errorf("%s lacks Contract/Safety", command)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil {
t.Errorf("%s public availability drift", command)
}
} else {
unavailable++
if availability != shortcut.AvailabilityUnavailable || !item.Hidden || shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
t.Errorf("%s unavailable visibility drift", command)
}
}
}
if public != 1 || unavailable != 2 {
t.Fatalf("public/unavailable = %d/%d, want 1/2", public, unavailable)
}
}
@@ -36,8 +36,8 @@ func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurfa
}
registered[item.Command] = item
}
if len(registered) != 100 || len(source.Shortcuts) != 100 {
t.Fatalf("registered/catalog = %d/%d, want 100/100", len(registered), len(source.Shortcuts))
if len(registered) != 93 || len(source.Shortcuts) != 93 {
t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts))
}
var missing, stale []string
-6
View File
@@ -25,28 +25,22 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
// Service packages — each registers its shortcuts from init().
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendar"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devdoc"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/hrbrain"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/live"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/pat"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki"
"github.com/spf13/cobra"
@@ -1,104 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageContactSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_contact.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "contact" {
t.Fatalf("service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "contact" {
registered[item.Command] = item
}
}
if len(registered) != 16 || len(source.Shortcuts) != 16 {
t.Fatalf("registered/catalog = %d/%d, want 16/16", len(registered), len(source.Shortcuts))
}
wantCompatibilityVisible := map[string]bool{"+list-roles": true}
public, unavailable := 0, 0
for command, record := range source.Shortcuts {
item, ok := registered[command]
if !ok {
t.Errorf("catalog contains stale %s", command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
t.Errorf("%s semantic facts drifted", command)
}
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" {
t.Errorf("%s lacks Contract/Safety", command)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil {
t.Errorf("%s public availability drift", command)
}
} else {
unavailable++
if availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
t.Errorf("%s unavailable visibility drift", command)
}
if record.CompatibilityVisible {
if item.Hidden || !item.CompatibilityVisible {
t.Errorf("%s compatibility visibility drift", command)
}
} else if !item.Hidden || item.CompatibilityVisible {
t.Errorf("%s hidden unavailable visibility drift", command)
}
}
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
}
if command == "+search-mobile" || command == "+by-mobile" {
if !strings.Contains(record.SemanticDelta, "专用手机号精确查询接口") {
t.Errorf("%s must describe the dedicated exact mobile interface", command)
}
if strings.Contains(record.SemanticDelta, "关键词能力") || strings.Contains(record.SemanticDelta, "关键词数组") {
t.Errorf("%s must not describe the retired keyword-search route", command)
}
}
if command == "+list-roster-fields" || command == "+get-roster" {
if !strings.Contains(record.SemanticDelta, "历史 CLI 继续透传原 MCP 调用与真实错误") {
t.Errorf("%s must preserve the historical direct CLI execution path", command)
}
if strings.Contains(record.SemanticDelta, "0 次远程调用") {
t.Errorf("%s must not claim a zero-call unavailable runtime", command)
}
}
if command == "+list-roles" {
if !strings.Contains(record.SemanticDelta, "历史 CLI 继续执行严格 get_org_labels 适配") || !strings.Contains(record.SemanticDelta, "整批拒绝") {
t.Errorf("%s must preserve strict historical CLI execution while remaining Agent unavailable", command)
}
if strings.Contains(record.SemanticDelta, "0 次远程调用") {
t.Errorf("%s must not claim a zero-call unavailable runtime", command)
}
}
}
if public != 13 || unavailable != 3 {
t.Fatalf("public/unavailable = %d/%d, want 13/3", public, unavailable)
}
}
@@ -1,55 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDevdocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_devdoc.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "devdoc" || len(source.Shortcuts) != 1 {
t.Fatalf("service/count = %q/%d", source.Service, len(source.Shortcuts))
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "devdoc" {
registered[item.Command] = item
}
}
if len(registered) != 1 {
t.Fatalf("registered Devdoc shortcuts = %d, want 1", len(registered))
}
for command, record := range source.Shortcuts {
item, ok := registered[command]
if !ok {
t.Fatalf("catalog entry %s is not registered", command)
}
if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable {
t.Fatalf("catalog entry is not reviewed private unavailable: %#v", record)
}
if !item.Hidden || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || item.SemanticDelta != record.SemanticDelta {
t.Fatalf("runtime semantic facts drifted: %#v", item)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.OutputRollout != output.RolloutUnifiedActive || strings.TrimSpace(item.Safety.Effect) == "" {
t.Fatal("unavailable Devdoc Shortcut lacks Contract/Result/Safety/unified output")
}
if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || !strings.Contains(string(item.Contract.Result.DataSchema), "zeroMatchProven") {
t.Fatal("unavailable Devdoc Shortcut does not publish the semantic zero-match boundary")
}
}
}
@@ -1,75 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageHRbrainSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_hrbrain.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "hrbrain" {
registered[item.Command] = item
}
}
if source.Service != "hrbrain" || len(source.Shortcuts) != 11 || len(registered) != 11 {
t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered))
}
var missing, stale []string
blockerCounts := map[string]int{}
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if record.Public || record.Availability != "" || !record.Reviewed || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || !item.Hidden {
t.Errorf("%s availability/review drift", command)
}
if strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic delta drift", command)
}
if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" {
t.Errorf("%s lacks Contract/Result/Safety/unified output", command)
}
if item.Contract.Interface == nil || strings.Contains(item.Contract.Interface.Reason, "shortcut_defect") {
t.Errorf("%s lacks a reviewed non-Shortcut blocker", command)
} else if strings.Contains(item.Contract.Interface.Reason, "classified=adapter_business_service") {
blockerCounts["adapter_business_service"]++
} else if strings.Contains(item.Contract.Interface.Reason, "classified=tenant_fixture") {
blockerCounts["tenant_fixture"]++
} else {
t.Errorf("%s has unknown blocker classification", command)
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if blockerCounts["adapter_business_service"] != 9 || blockerCounts["tenant_fixture"] != 2 {
t.Fatalf("blocker counts = %#v", blockerCounts)
}
}
@@ -1,51 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageLiveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_live.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "live" {
registered[item.Command] = item
}
}
if source.Service != "live" || len(registered) != 1 || len(source.Shortcuts) != 1 {
t.Fatalf("service/registered/catalog = %q/%d/%d, want live/1/1", source.Service, len(registered), len(source.Shortcuts))
}
record := source.Shortcuts["+list-my-lives"]
item, ok := registered["+list-my-lives"]
if !ok {
t.Fatal("registered Live shortcut missing")
}
if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable {
t.Fatalf("semantic record = %#v", record)
}
if !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
t.Fatal("Live semantic facts drifted")
}
if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("live", item.Command) {
t.Fatalf("Live visibility drift: hidden=%v availability=%q", item.Hidden, item.Availability)
}
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || item.Contract.Result != nil || item.OutputRollout != output.RolloutLegacyOnly {
t.Fatal("Live unavailable shortcut contract drift")
}
}

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