Merge remote-tracking branch 'origin/main' into pr-922

This commit is contained in:
玉澜
2026-08-10 22:36:07 +08:00
55 changed files with 6520 additions and 2945 deletions
+1
View File
@@ -39,6 +39,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
## [1.0.58-beta.1] - 2026-08-07
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
text := c.paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
+8 -19
View File
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "doc +version-list",
Aliases: map[string]string{
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"max-result": "limit",
"max-results": "limit",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
"url": "node",
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
CLIPath: "doc +version-revert",
+1
View File
@@ -44,6 +44,7 @@
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
+66 -18
View File
@@ -532,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
fileSize := fileInfo.Size()
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
deps.Out.PrintKeyValue("文档", nodeID)
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("类型", mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"dry_run": true,
"preview_kind": "plan",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"executed": false, "nodeId": nodeID, "file": filePath,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
},
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
})
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
@@ -568,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"Content-Type": mimeType,
}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return err
return apperrors.NewAPI(
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_upload_unknown"),
apperrors.WithFailureStage("upload_oss"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
}),
apperrors.WithCause(err),
)
}
// Step 3: insert block into document
@@ -625,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
return err
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_partial"),
apperrors.WithFailureStage("insert_block"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "unknown"},
},
}),
apperrors.WithCause(err),
)
}
if strings.HasPrefix(mimeType, "image/") {
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
} else {
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
}
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
},
})
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
@@ -3831,8 +3879,8 @@ CLI 内部自动完成全部流程:
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
importCmd.Flags().String("folder-id", "", "")
_ = importCmd.Flags().MarkHidden("folder-id")
+36 -30
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -17,9 +18,6 @@ const (
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// minChunkSize is the floor; below this we report an error instead of retrying.
minChunkSize = 5000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
)
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
// Timeout on single write — fallback to chunked with halved size
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
}
} else {
// Chunked write path
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
newSize := chunkSize / 2
if newSize < minChunkSize {
return nodeID, writtenCount, resultText, &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
writtenCount, chunkSize, minChunkSize),
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
nodeID, nodeID),
Operation: operation,
}
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
chunkSize = newSize
var remaining strings.Builder
remaining.WriteString(chunk)
for j := i + 1; j < len(chunks); j++ {
remaining.WriteString(chunks[j])
}
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
chunks = append(chunks[:i], newChunks...)
i-- // retry current index
continue
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
}
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
return nodeID, writtenCount, lastResponse, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
"chunksWritten": written,
"chunksTotal": total,
"failedStage": stage,
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_commit_unknown"),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
// isTimeoutError checks if an error message indicates a server-side timeout.
func isTimeoutError(msg string) bool {
lower := strings.ToLower(msg)
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
t.Fatalf("long literal single write: %v", err)
}
fallback := strings.Repeat("x", minChunkSize+100)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
t.Fatalf("single timeout fallback: %v", err)
fallback := strings.Repeat("x", 5100)
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
installScriptedCaller(t, timeoutCaller)
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
}
chunked := strings.Repeat("x", initialChunkSize+100)
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err != nil || written < 3 {
t.Fatalf("adaptive retry written=%d err=%v", written, err)
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
}
}
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
tests := []struct {
name string
tool string
args map[string]any
wantNode string
}{
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
}
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown commit metadata = %#v", typed)
}
})
}
}
@@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
"+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save",
"+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
+189 -15
View File
@@ -5,6 +5,7 @@ package doc
import (
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
@@ -16,7 +17,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
const (
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
)
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
@@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
cwd, err := docGetwd()
if err != nil {
@@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
data, err := docReadFile(realPath)
if err != nil {
@@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
return string(data), nil
}
func validateWorkspaceInputPath(flag, raw string) error {
path := strings.TrimSpace(raw)
if path == "" || filepath.IsAbs(path) {
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
}
return nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
root, ok := value.([]any)
if !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
if len(root) == 0 {
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
}
if _, nestedRoot := root[0].([]any); nestedRoot {
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
}
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
type docFailureState string
const (
docFailureFailed docFailureState = "failed"
docFailureRetryable docFailureState = "retryable"
docFailureUnknown docFailureState = "unknown"
)
type docFailureTransition struct {
state docFailureState
reason string
retryable bool
executionStarted bool
message string
actions []string
}
func classifyDocWriteFailure(cause error) docFailureTransition {
transition := docFailureTransition{
state: docFailureUnknown,
reason: "doc_write_commit_unknown",
retryable: false,
executionStarted: true,
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
}
var typed *apperrors.Error
if !errors.As(cause, &typed) {
return transition
}
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(typed.Message)
if typed.Category == apperrors.CategoryValidation {
if reason == "" {
reason = "invalid_input"
}
return docFailureTransition{
state: docFailureFailed,
reason: reason,
retryable: false,
executionStarted: false,
message: typed.Message,
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
}
}
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
if permissionDenied {
return docFailureTransition{
state: docFailureFailed,
reason: "permission_denied",
retryable: false,
executionStarted: false,
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
}
}
if typed.Category == apperrors.CategoryAuth {
return docFailureTransition{
state: docFailureFailed,
reason: "authentication_required",
retryable: false,
executionStarted: false,
message: "当前身份认证不可用;已终止文档写入",
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
}
}
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
transition.state = docFailureFailed
transition.executionStarted = false
transition.retryable = typed.RetryableSet && typed.Retryable
if transition.retryable {
transition.state = docFailureRetryable
}
if reason != "" {
transition.reason = reason
} else {
transition.reason = "doc_write_not_started"
}
transition.message = "文档写入已确认尚未开始"
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
}
return transition
}
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
var existing *apperrors.Error
if errors.As(cause, &existing) {
if existing.Category == apperrors.CategoryValidation {
return cause
}
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
return cause
}
}
transition := classifyDocWriteFailure(cause)
details := map[string]any{
"contractVersion": "doc.operation.v1",
"status": string(transition.state),
"state": string(transition.state),
"nodeId": nodeID,
"stage": stage,
}
options := []apperrors.Option{
apperrors.WithOperation(operation),
apperrors.WithReason(transition.reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(transition.executionStarted),
apperrors.WithRetryable(transition.retryable),
apperrors.WithActions(transition.actions...),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
}
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
return apperrors.NewAuth(transition.message, options...)
}
return apperrors.NewAPI(transition.message, options...)
}
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
return docPartialWriteError(
operation,
"doc_write_verification_failed",
stage,
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
cause,
map[string]any{"nodeId": nodeID, "verified": false},
steps,
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
)
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
@@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error,
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
"contractVersion": "doc.operation.v1",
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateWorkspaceInputPath("file", "staged.md"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
stub func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
}},
{"base symlink", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") })
}},
{"file symlink", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("file")
}
return path, nil
})
}},
{"relative escape", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil })
}},
{"relative error", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
tc.stub(t)
if err := validateWorkspaceInputPath("file", "staged.md"); err == nil {
t.Fatal("expected validation failure")
}
})
}
if _, err := validateJSONML(`["",{}]`); err == nil {
t.Fatal("empty JSONML tag succeeded")
}
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
if validation.reason != "invalid_input" || validation.executionStarted {
t.Fatalf("validation transition = %#v", validation)
}
permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden"))
if permission.reason != "permission_denied" {
t.Fatalf("permission transition = %#v", permission)
}
auth := classifyDocWriteFailure(apperrors.NewAuth("login"))
if auth.reason != "authentication_required" {
t.Fatalf("auth transition = %#v", auth)
}
notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)))
if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" {
t.Fatalf("retryable transition = %#v", notStarted)
}
for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} {
var typed *apperrors.Error
if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
t.Fatalf("auth-classified error = %#v", err)
}
}
for _, existing := range []error{
apperrors.NewValidation("existing"),
apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})),
} {
if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing {
t.Fatalf("existing classified error was wrapped: %p != %p", got, existing)
}
}
if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") ||
!containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") ||
!containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") {
t.Fatal("containsText branch contract failed")
}
jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"}
jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`}
for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} {
if canonicalBlockContent(value, "jsonml") == "" {
t.Fatalf("empty canonical JSONML for %#v", value)
}
}
for _, value := range []any{
map[string]any{"text": "alpha"},
map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}},
[]any{"p", map[string]any{"text": "gamma"}},
[]any{map[string]any{"text": "delta"}},
"epsilon",
} {
if canonicalBlockContent(value, "markdown") == "" {
t.Fatalf("empty canonical markdown for %#v", value)
}
}
jsonTree := map[string]any{
"jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`,
"content": "not-json",
"nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"},
}
if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil {
t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree))
}
if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" {
t.Fatal("JSONML identity negative contract failed")
}
if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" {
t.Fatal("JSONML canonical identity failed")
}
elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}}
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
t.Fatal("element canonical traversal failed")
}
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
t.Fatal("inserted block verification branch contract failed")
}
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
t.Fatal("missing block matched")
}
if blockContentEquals(nil, "missing", "after", "element") {
t.Fatal("nil block tree matched")
}
if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") {
t.Fatal("missing JSONML block matched")
}
if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" {
t.Fatalf("root content candidates = %#v", got)
}
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
t.Fatalf("nested content candidates = %#v", got)
}
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
t.Fatalf("disabled split = %#v", got)
}
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
t.Fatalf("newline split = %#v", got)
}
}
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
longContent := strings.Repeat("x", 10001)
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
t.Fatal("partial chunk create succeeded")
}
for _, failAt := range []int{1, 2} {
err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}},
"--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes")
if err == nil {
t.Fatalf("block mutation failAt=%d succeeded", failAt)
}
}
mismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}},
}}
if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil {
t.Fatal("mismatched block verification succeeded")
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}},
{"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}},
{"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}},
{"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...)
})
}
t.Run("export getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
t.Run("export download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil {
t.Fatal(err)
}
if got := searchCaller.history[0].params["pageSize"]; got != 10 {
t.Fatalf("default search page size = %#v", got)
}
listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil {
t.Fatal(err)
}
if got := listCaller.history[0].params["pageSize"]; got != 50 {
t.Fatalf("default list page size = %#v", got)
}
_ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes")
if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad")
_ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx")
if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}},
{"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}},
{"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}},
{"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...)
})
}
for _, raw := range [][]string{{"[bad"}, {"", " "}} {
if _, err := normalizeMentionUserIDs(raw); err == nil {
t.Fatalf("invalid mentions %#v succeeded", raw)
}
}
if _, ok := sliceUTF16Range("A😀B", 1, 2); ok {
t.Fatal("selection ending inside surrogate pair succeeded")
}
if _, ok := sliceUTF16Range("abc", -1, 1); ok {
t.Fatal("negative selection range succeeded")
}
commentCases := []struct {
name string
caller *docCoverageCaller
args []string
wantError bool
}{
{"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true},
{"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true},
{"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true},
}
for _, tc := range commentCases {
t.Run(tc.name, func(t *testing.T) {
err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...)
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("array version evidence did not match")
}
if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"})
if len(withDescription) != 1 || withDescription[0]["description"] != "d" {
t.Fatalf("template description = %#v", withDescription)
}
duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false},
}}}
_ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q")
stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
}}}
if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil {
t.Fatal("stalled template cursor succeeded")
}
maxPages := make([]map[string]any, 20)
for index := range maxPages {
maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)}
}
if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil {
t.Fatal("template max-pages succeeded")
}
t.Chdir(t.TempDir())
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil {
t.Fatal(err)
}
t.Run("empty preview", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("empty download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out")
})
}
+142 -108
View File
@@ -17,8 +17,11 @@
package doc
import (
"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/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
},
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{
if rt.Changed("workspace-ids") {
params["workspaceIds"] = rt.StrSlice("workspace-ids")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 10
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "search_documents", params)
result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "documents": docs})
return rt.Output(result)
},
}
@@ -241,6 +246,9 @@ var List = shortcut.Shortcut{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
},
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -251,18 +259,17 @@ var List = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 50
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "list_nodes", params)
result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
nodes := listNodesProject(data)
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
return rt.Output(result)
},
}
@@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{
Command: "+comment-list",
Product: productComment,
Description: "查询文档评论列表",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档评论列表",
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"},
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws doc +comment-list --node DOC_ID",
"dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved",
"dws doc +comment-list --node DOC_ID --limit 20",
"dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN",
},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"},
{Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}},
{Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}},
},
Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`},
Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
if rt.Changed("limit") || rt.Changed("page-size") {
params["pageSize"] = rt.IntFirst("limit", "page-size")
}
if v := rt.Str("cursor"); v != "" {
params["nextToken"] = v
@@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{
},
}
const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配"
var CommentCreate = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create",
Product: productComment,
Description: "在文档上创建一条评论",
Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。",
Description: "创建全文评论,或按 selection 创建划词评论",
Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "shortcut_comment_create",
CanonicalPath: "doc.shortcut_comment_create",
CLIPath: "doc +comment-create",
PrimaryCLIPath: "doc +comment-create",
},
Description: "在文档上创建一条评论",
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: "在文档上创建一条评论",
UseWhen: []string{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""},
},
},
Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论",
"当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
[]string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "content", Property: "content"},
contract.ParamDecl{Name: "selection", Property: "selection"},
contract.ParamDecl{Name: "block-id", Property: "blockId"},
contract.ParamDecl{Name: "start", Property: "start"},
contract.ParamDecl{Name: "end", Property: "end"},
contract.ParamDecl{Name: "selected-text", Property: "selectedText"},
contract.ParamDecl{Name: "mention", Property: "mention"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_comment", params)
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}},
Tips: []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
var CommentReply = shortcut.Shortcut{
@@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{
{Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true},
{Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{
params["emoji"] = true
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("reply_comment", params)
},
@@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create-inline",
Product: productComment,
Description: "在文档选中文本区域上创建划词评论",
Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。",
Description: "兼容入口:按 block/start/end 创建划词评论",
Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
@@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
"blockId": rt.Str("block-id"),
"start": rt.Int("start"),
"end": rt.Int("end"),
}
if v := rt.Str("selected-text"); v != "" {
params["selectedText"] = v
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_inline_comment", params)
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}},
Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
// ── 协作权限 ─────────────────────────────────────────────────
@@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{
Command: "+export-submit",
Product: productDoc,
Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。",
Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"},
AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"},
Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}},
},
Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`},
Execute: func(rt *shortcut.RuntimeContext) error {
format := rt.Str("export-format")
if format == "" {
format = "docx"
}
return rt.CallMCP("submit_export_job", map[string]any{
result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{
"nodeId": rt.Str("node"),
"exportFormat": format,
"exportFormat": rt.Str("export-format"),
})
if err != nil {
return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"}))
},
}
@@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{
Command: "+export-get",
Product: productDoc,
Description: "根据 jobId 查询文档导出任务结果",
Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。",
Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 jobId 查询文档导出任务结果",
UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +export-get --job-id JOB_ID"},
UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"},
AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"},
Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"},
},
},
Flags: []shortcut.Flag{
{Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"},
},
Tips: []string{`dws doc +export-get --job-id JOB_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")})
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("output") == "" {
return nil
}
return localio.ValidateOutput(rt.Str("output"))
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`},
Execute: executeExportGet,
}
// ── 历史版本 (server: doc) ───────────────────────────────────
@@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{
},
Tips: []string{`dws doc +template-search --query "周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"searchName": rt.Str("query")}
pageSize := rt.Int("limit")
if pageSize <= 0 {
pageSize = 50
}
params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize}
if v := rt.Str("source"); v != "" {
params["templateSource"] = v
}
if rt.Changed("limit") {
params["maxResults"] = rt.Int("limit")
}
if v := rt.Str("cursor"); v != "" {
params["nextCursor"] = v
}
return rt.CallMCP("search_doc_templates", params)
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
candidates := collectTemplateCandidates(found)
hasMore, hasMoreKnown, nextCursor := docPageState(found)
nextCursor = strings.TrimSpace(nextCursor)
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize {
complete = true
}
globalComplete := complete && rt.Str("cursor") == ""
status := "selection_required"
if globalComplete && len(candidates) == 0 {
status = "not_found"
} else if globalComplete && len(candidates) == 1 {
status = "resolved"
}
selectedTemplateID := ""
if status == "resolved" {
selectedTemplateID = candidates[0]["templateId"].(string)
}
nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status]
if !complete {
nextAction = "continue_search"
}
return rt.Output(docEnvelope("doc.template_search", map[string]any{
"query": rt.Str("query"),
"source": rt.Str("source"),
"count": len(candidates),
"candidates": candidates,
"complete": complete,
"hasMore": !complete,
"nextCursor": nextCursor,
"selection": map[string]any{
"status": status,
"templateId": selectedTemplateID,
"nextAction": nextAction,
},
}))
},
}
@@ -986,7 +1021,6 @@ func init() {
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -998,9 +1032,9 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
compatHistorySave,
compatHistoryList,
compatHistoryRevert,
VersionSave,
VersionList,
VersionRevert,
+695 -40
View File
@@ -7,15 +7,18 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -26,8 +29,10 @@ import (
)
type docCoverageCaller struct {
mu sync.Mutex
failAt int
calls int
dryRun bool
responses map[string][]map[string]any
ctx context.Context
history []docCoverageCall
@@ -43,12 +48,17 @@ type docCoverageErrorReader struct{}
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
if f.failAt == f.calls {
return nil, errors.New("injected doc coverage failure")
}
value := docCoveragePayload(tool)
if tool == "revert_doc_version" {
value = map[string]any{"version": params["version"]}
}
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
f.responses[tool] = queue[1:]
@@ -61,7 +71,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
}
func (f *docCoverageCaller) Format() string { return "json" }
func (f *docCoverageCaller) DryRun() bool { return false }
func (f *docCoverageCaller) DryRun() bool { return f.dryRun }
func (f *docCoverageCaller) Fields() string { return "" }
func (f *docCoverageCaller) JQ() string { return "" }
@@ -70,9 +80,9 @@ func docCoveragePayload(tool string) map[string]any {
case "create_document":
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
case "get_document_content":
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
return map[string]any{"data": map[string]any{"revision": 1, "markdown": "alpha beta x body gamma legacy []", "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta x body gamma legacy []"]]`}}
case "list_document_blocks":
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta x body gamma []"}}}}}
case "submit_export_job":
return map[string]any{"jobId": "job-1"}
case "query_export_job":
@@ -156,6 +166,224 @@ func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testin
}
}
func TestCrossPlatformCoverageFetchResolvesUniqueTitleBeforeRead(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "resolved-node", "name": "项目周报", "docType": "adoc"}},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, caller, "--query", "项目周报"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "search_documents" || caller.history[1].tool != "get_document_content" {
t.Fatalf("fetch resolution calls = %#v", caller.history)
}
if caller.history[1].params["nodeId"] != "resolved-node" {
t.Fatalf("resolved fetch params = %#v", caller.history[1].params)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a", "name": "项目周报", "docType": "adoc"},
map[string]any{"nodeId": "b", "name": "项目周报", "docType": "adoc"},
},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, ambiguous, "--query", "项目周报"); err == nil {
t.Fatal("ambiguous title unexpectedly succeeded")
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_documents" {
t.Fatalf("ambiguous fetch reached content read: %#v", ambiguous.history)
}
}
func TestCrossPlatformCoverageTemplateGoldenRouteStopsBeforeAmbiguousCreate(t *testing.T) {
payload := map[string]any{
"templates": []any{
map[string]any{"templateId": "template-b", "templateName": "团队周报", "templateSource": "PUBLIC"},
map[string]any{"templateId": "template-a", "templateName": "运营周报", "templateSource": "PUBLIC"},
},
}
candidates := collectTemplateCandidates(payload)
if len(candidates) != 2 || candidates[0]["templateId"] != "template-b" || candidates[1]["templateId"] != "template-a" {
t.Fatalf("template candidates = %#v", candidates)
}
search := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
if err := runDocCoverage(t, TemplateSearch, search, "--query", "周报", "--source", "PUBLIC"); err != nil {
t.Fatal(err)
}
if len(search.history) != 1 || search.history[0].tool != "search_doc_templates" {
t.Fatalf("template search calls = %#v", search.history)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报", "--source", "PUBLIC")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" || typed.ExecutionStarted == nil || *typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("ambiguous template error = %#v", err)
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_doc_templates" {
t.Fatalf("ambiguous template reached write: %#v", ambiguous.history)
}
if got, ok := typed.Details["candidates"].([]map[string]any); !ok || len(got) != 2 {
t.Fatalf("ambiguous template details = %#v", typed.Details)
}
notFound := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}}}}}
err = runDocCoverage(t, CreateFromTemplate, notFound, "--query", "不存在")
if !errors.As(err, &typed) || typed.Reason != "template_not_found" {
t.Fatalf("not-found template error = %#v", err)
}
if selection := CreateFromTemplate.Contract.Selection; len(selection.AvoidWhen) < 2 || !strings.Contains(strings.Join(selection.AvoidWhen, " "), "+template-search") {
t.Fatalf("create-from-template selection = %#v", selection)
}
mutualRoutes := []struct {
name string
selection contract.SelectionSpec
siblings []string
}{
{name: "+template-list", selection: TemplateList.Contract.Selection, siblings: []string{"+template-search", "+create-from-template"}},
{name: "+template-search", selection: TemplateSearch.Contract.Selection, siblings: []string{"+template-list", "+create-from-template"}},
{name: "+create-from-template", selection: CreateFromTemplate.Contract.Selection, siblings: []string{"+template-list", "+template-search"}},
}
for _, route := range mutualRoutes {
avoid := strings.Join(route.selection.AvoidWhen, " ")
for _, sibling := range route.siblings {
if !strings.Contains(avoid, sibling) {
t.Errorf("%s Selection does not exclude sibling %s: %#v", route.name, sibling, route.selection)
}
}
}
}
func TestCrossPlatformCoverageTemplateCreateProvesUniquenessAcrossPages(t *testing.T) {
firstPage := map[string]any{
"templates": []any{map[string]any{"templateId": "template-a", "templateName": "周报"}},
"hasMore": true,
"nextCursor": "page-2",
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{map[string]any{"templateId": "template-b", "templateName": "周报二"}}, "hasMore": false},
},
}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" {
t.Fatalf("cross-page ambiguity = %#v", err)
}
if len(ambiguous.history) != 2 || ambiguous.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("cross-page search calls = %#v", ambiguous.history)
}
unique := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{}, "hasMore": false},
},
}}
if err := runDocCoverage(t, CreateFromTemplate, unique, "--query", "周报"); err != nil {
t.Fatal(err)
}
if len(unique.history) != 3 || unique.history[2].tool != "apply_doc_template" || unique.history[2].params["templateId"] != "template-a" {
t.Fatalf("unique cross-page create calls = %#v", unique.history)
}
missingCursor := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {firstPage}}}
missingCursor.responses["search_doc_templates"][0] = map[string]any{
"templates": []any{map[string]any{"templateId": "template-a"}},
"hasMore": true,
}
err = runDocCoverage(t, CreateFromTemplate, missingCursor, "--query", "周报")
if !errors.As(err, &typed) || typed.Reason != "template_pagination_missing_next_cursor" || len(missingCursor.history) != 1 {
t.Fatalf("missing template cursor = %#v history=%#v", err, missingCursor.history)
}
}
func TestCrossPlatformCoverageExportRecoveryReusesJobAndSafeDownloader(t *testing.T) {
for _, status := range []string{"INIT", "init", " PROCESSING "} {
if !docExportStatusPollable(status) {
t.Errorf("export status %q should remain pollable", status)
}
}
for _, status := range []string{"", "FAILED", "CANCELLED", "SUCCESS"} {
if docExportStatusPollable(status) {
t.Errorf("export status %q should not remain pollable", status)
}
}
init := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "INIT"}}}}
err := runDocCoverage(t, Export, init, "--node", "n", "--export-format", "docx", "--output", "export.docx", "--max-polls", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_timeout" {
t.Fatalf("INIT export status = %#v, want poll timeout", err)
}
pollFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Export, pollFailure, "--node", "n", "--export-format", "docx", "--output", "export.docx")
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_failed" || typed.Details["jobId"] != "job-1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
t.Fatalf("export poll error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "+export-get --job-id job-1") {
t.Fatalf("export recovery actions = %#v", typed.Actions)
}
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, opts localio.DownloadOptions) (localio.DownloadResult, error) {
if opts.Output != "recovered.docx" {
t.Fatalf("recovery output = %#v", opts)
}
return localio.DownloadResult{RelativePath: "recovered.docx", SizeBytes: 42}, nil
})
recovery := &docCoverageCaller{responses: map[string][]map[string]any{
"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/recovered.docx"}},
}}
if err := runDocCoverage(t, ExportGet, recovery, "--job-id", "job-1", "--output", "recovered.docx"); err != nil {
t.Fatal(err)
}
if len(recovery.history) != 1 || recovery.history[0].tool != "query_export_job" {
t.Fatalf("export recovery calls = %#v", recovery.history)
}
if err := runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--job-id", "job-1", "--output", "/tmp/unsafe.docx"); err == nil {
t.Fatal("absolute recovery output unexpectedly accepted")
}
}
func TestCrossPlatformCoverageCommentListPaginationFlagsAndAlias(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentList, caller, "--node", "n", "--page-size", "20", "--cursor", "next-1"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 1 || caller.history[0].tool != "list_comments" || caller.history[0].params["pageSize"] != 20 || caller.history[0].params["nextToken"] != "next-1" {
t.Fatalf("comment pagination call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageMediaFailuresKeepStableIDsAndForbidPathEscape(t *testing.T) {
if err := runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "/tmp/media.bin", "--dry-run", "--yes"); err == nil {
t.Fatal("absolute media input unexpectedly accepted")
}
if err := runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "/tmp/report.docx", "--workspace", "workspace-1"); err == nil {
t.Fatal("absolute import input unexpectedly accepted")
}
resolveFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, MediaDownload, resolveFailure, "--node", "node-1", "--resource-id", "resource-1", "--output", "media.bin")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_media_resolve_failed" || typed.Details["nodeId"] != "node-1" || typed.Details["resourceId"] != "resource-1" {
t.Fatalf("media recovery error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "不要 curl/wget") {
t.Fatalf("media recovery actions = %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
t.Helper()
@@ -184,7 +412,7 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
}
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml")
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
compensation, _ := typed.Details["compensation"].(map[string]any)
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
@@ -206,6 +434,13 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
checkpointMismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"save_doc_version": {{"version": 8.0}},
"get_document_content": {{"markdown": "different content"}},
}}
err = runDocCoverage(t, CheckpointUpdate, checkpointMismatch, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
@@ -215,22 +450,31 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
tests := []struct {
name string
args []string
responses map[string][]map[string]any
wantTools []string
}{
{
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
wantTools: []string{"list_document_blocks", "update_document_block"},
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "gamma beta"}}}}},
}},
wantTools: []string{"list_document_blocks", "update_document_block", "list_document_blocks"},
},
{
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
wantTools: []string{"list_document_blocks", "insert_document_block"},
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "id-1", "paragraph": map[string]any{"text": "alpha"}}}}},
}},
wantTools: []string{"list_document_blocks", "insert_document_block", "list_document_blocks"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
caller := &docCoverageCaller{responses: tc.responses}
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
t.Fatal(err)
}
@@ -246,6 +490,246 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
}
}
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_commit_unknown" || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown write error = %#v", err)
}
if len(unknown.history) != 1 || unknown.history[0].tool != "update_document" {
t.Fatalf("unknown write was replayed or verified: %#v", unknown.history)
}
verificationFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Update, verificationFailure, "--node", "n", "--command", "append", "--content", "x", "--yes")
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.Details["status"] != "partial_success" {
t.Fatalf("verification failure = %#v", err)
}
if len(verificationFailure.history) != 2 || verificationFailure.history[1].tool != "get_document_content" {
t.Fatalf("verification calls = %#v", verificationFailure.history)
}
}
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "truncated"}},
}}
err := runDocCoverage(t, Create, caller, "--name", "n", "--content", "complete body")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.FailureStage != "verify" {
t.Fatalf("create mismatch error = %#v, want verification failure", err)
}
if len(caller.history) != 2 || caller.history[0].tool != "create_document" || caller.history[1].tool != "get_document_content" {
t.Fatalf("create mismatch calls = %#v, want one write followed by one read", caller.history)
}
}
func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *testing.T) {
for _, test := range []struct {
name string
command string
blockArg string
response string
}{
{name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`},
{name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`},
} {
t.Run(test.name, func(t *testing.T) {
blockID := "ref"
if test.command == "block_replace" {
blockID = "target"
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {{"jsonml": test.response}},
}}
err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", test.command, test.blockArg, blockID,
"--content", `["p",{},"after"]`, "--doc-format", "jsonml", "--yes")
if err != nil {
t.Fatal(err)
}
if got := caller.history[len(caller.history)-1].params["format"]; got != "jsonml" {
t.Fatalf("verification format = %#v, want jsonml", got)
}
})
}
}
func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) {
t.Run("target on second page", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_doc_versions": {
{"versions": []any{map[string]any{"version": 1.0}}, "hasMore": true, "nextCursor": "page-2"},
{"versions": []any{map[string]any{"version": 3.0}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 4 || caller.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("version pagination calls = %#v", caller.history)
}
})
t.Run("empty write response and ordinary document info", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"revert_doc_version": {{}},
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
})
for _, test := range []struct {
name string
responses []map[string]any
want string
}{
{name: "not found", responses: []map[string]any{{"versions": []any{}, "hasMore": false}}, want: "目标版本 3 不存在"},
{name: "missing cursor", responses: []map[string]any{{"versions": []any{}, "hasMore": true}}, want: "无法证明分页已经完整"},
{name: "stalled cursor", responses: []map[string]any{
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
}, want: "无法证明分页已经完整"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": test.responses}}
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("version preflight error = %v, want %q", err, test.want)
}
for _, call := range caller.history {
if call.tool == "revert_doc_version" {
t.Fatalf("preflight failure executed revert: %#v", caller.history)
}
}
})
}
pages := make([]map[string]any, 20)
for index := range pages {
pages[index] = map[string]any{"versions": []any{}, "hasMore": true, "nextCursor": fmt.Sprintf("page-%d", index+1)}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": pages}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err == nil || !strings.Contains(err.Error(), "无法证明分页已经完整") {
t.Fatalf("max-pages preflight error = %v", err)
}
for _, value := range []any{json.Number("3"), 3, "3"} {
if !versionNumberMatches(value, 3) {
t.Errorf("version value %#v did not match", value)
}
}
for _, value := range []any{json.Number("bad"), 4, "bad", true} {
if versionNumberMatches(value, 3) {
t.Errorf("version value %#v unexpectedly matched", value)
}
}
if !versionEvidenceMatches([]any{map[string]any{"target-version": "3"}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
if versionEvidenceMatches(map[string]any{"version": 4}, 3, map[string]bool{"version": true}) {
t.Fatal("mismatched version evidence unexpectedly matched")
}
}
func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) {
tests := []struct {
name string
cause error
wantState string
wantReason string
wantRetry bool
}{
{
name: "permission is terminal",
cause: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"), apperrors.WithRetryable(false)),
wantState: "failed",
wantReason: "permission_denied",
},
{
name: "confirmed not started may retry",
cause: apperrors.NewAPI("rate limited", apperrors.WithReason("http_429"),
apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)),
wantState: "retryable",
wantReason: "http_429",
wantRetry: true,
},
{
name: "unknown write never replays",
cause: errors.New("connection reset"),
wantState: "unknown",
wantReason: "doc_write_commit_unknown",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := docUnknownWriteError("doc.update", "update_document", "n", tc.cause)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if typed.Details["state"] != tc.wantState || typed.Reason != tc.wantReason || typed.Retryable != tc.wantRetry {
t.Fatalf("transition = reason=%q retry=%v details=%#v", typed.Reason, typed.Retryable, typed.Details)
}
})
}
}
func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
long := strings.Repeat("段落😀", 4000)
chunks := splitDocMarkdown(long, 10000)
if len(chunks) < 2 || strings.Join(chunks, "") != long {
t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long)
}
update := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil {
t.Fatal(err)
}
if len(update.history) != len(chunks)+1 || update.history[0].params["mode"] != "overwrite" || update.history[1].params["mode"] != "append" || update.history[len(update.history)-1].tool != "get_document_content" {
t.Fatalf("long update calls = %#v", update.history)
}
partial := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, partial, "--node", "n", "--command", "append", "--content", long, "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_update_chunk_commit_unknown" || len(partial.history) != 2 {
t.Fatalf("partial long update err=%#v calls=%#v", err, partial.history)
}
create := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil {
t.Fatal(err)
}
if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" {
t.Fatalf("long create calls = %#v", create.history)
}
}
func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) {
expected := "# 值班表\r\n\r\n| 姓名 | 班次 |\r\n| --- | --- |\r\n| 小王 | 早班 |\r\n"
serverMarkdown := "# 值班表\n\n|姓名|班次|\n|---|---|\n|小王|早班|\n"
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"data": map[string]any{"markdown": serverMarkdown}}},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", expected, "--yes"); err != nil {
t.Fatalf("normalized Markdown verification failed: %v", err)
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "已有内容\n" + serverMarkdown}, expected, "append", "markdown") {
t.Fatal("normalized append verification did not find the appended content")
}
if verifyUpdatedDocumentContent(map[string]any{"markdown": "# 值班表\n|姓名|班次|"}, expected, "overwrite", "markdown") {
t.Fatal("incomplete overwrite content passed normalized verification")
}
}
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
tests := []struct {
name string
@@ -378,20 +862,20 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
{"inspect base", Inspect, []string{"--node", "n"}},
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}},
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
{"export dry", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
}
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -400,6 +884,43 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
switch tc.name {
case "create markdown":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "body"}}
case "create jsonml file":
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{},"body"]`}}
case "create stdin":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "stdin body"}}
}
if tc.name == "update overwrite jsonml" || tc.name == "update revision" {
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{}]`}}
}
switch tc.name {
case "update append":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
case "update insert text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}}
case "update insert jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}}
case "update replace text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "x"}}}}
case "update replace jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"x"]]`}}
case "update delete":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "other", "text": "x"}}}}
case "update replace":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "block-1", "text": "gamma beta"}}},
}
case "update copy":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "alpha beta"}}},
}
case "checkpoint success":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
}
var err error
if tc.name == "create stdin" {
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
@@ -415,18 +936,73 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
for failAt := 1; failAt <= 7; failAt++ {
args := map[string][]string{
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
"+create": {"--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"},
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
"+update": {"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
"+export": {"--node", "n", "--output", "out.docx"},
"+export": {"--node", "n", "--export-format", "docx", "--output", "out.docx"},
}[command.Command]
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
}
func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
flags := make(map[string]shortcut.Flag, len(Update.Flags))
for _, flag := range Update.Flags {
flags[flag.Name] = flag
}
if !flags["node"].Required || flags["command"].Required {
t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required)
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
if got := flags[name].RequiredWhen; got != "" {
t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got)
}
}
if len(Update.Constraints) != 1 || Update.Constraints[0].Kind != shortcut.ConstraintCustom ||
!strings.Contains(Update.Constraints[0].Description, "依 command 校验") {
t.Fatalf("update custom constraint = %#v", Update.Constraints)
}
cmd := corecmd.New(shortcut.FromShortcut(Update))
for _, alias := range []string{"doc", "text"} {
flag := cmd.Flags().Lookup(alias)
if flag == nil {
t.Errorf("compatibility alias --%s is not mounted", alias)
}
}
invalid := []struct {
name string
args []string
want string
}{
{name: "missing command", args: []string{"--node", "n"}, want: "--command"},
{name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"},
{name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"},
{name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"},
{name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"},
}
for _, test := range invalid {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, caller, test.args...)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
if strings.Contains(err.Error(), "confirmation_required") || caller.calls != 0 {
t.Fatalf("invalid input reached confirmation or execution: err=%v calls=%d", err, caller.calls)
}
})
}
if err := runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}},
"--node", "n", "--command", "str_replace", "--old", "x", "--new", "", "--dry-run"); err != nil {
t.Fatalf("explicit empty --new should satisfy str_replace contract: %v", err)
}
}
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
@@ -442,6 +1018,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Create, []string{"--name", "n", "--content", "@/absolute"}},
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
@@ -449,25 +1027,28 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
}
for _, tc := range badCases {
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
}
}
absoluteInputErr := runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@/absolute")
if absoluteInputErr == nil || !strings.Contains(absoluteInputErr.Error(), "暂存到工作目录") || !strings.Contains(absoluteInputErr.Error(), "stdin") {
t.Fatalf("absolute @file guidance = %v", absoluteInputErr)
}
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"); err == nil {
t.Fatal("jsonml create without node id succeeded")
}
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("revision conflict succeeded")
revision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"jsonml": `["root",{}]`}}}}
if err := runDocCoverage(t, Update, revision, "--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "7", "--yes"); err != nil {
t.Fatalf("server revision update failed: %v", err)
}
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("missing revision succeeded")
if len(revision.history) < 1 || revision.history[0].tool != "update_document" || revision.history[0].params["revision"] != 7 {
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
}
for _, value := range []any{
@@ -482,6 +1063,9 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
if _, err := validateJSONML(`{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil {
t.Fatal("nested element-array jsonml succeeded")
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
@@ -496,8 +1080,11 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
stripBlockIDs(blocks)
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("visible update flag aliases: %v", err)
aliasCaller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "legacy"}},
}}
if err := runDocCoveragePath(t, Update, aliasCaller, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("compatibility aliases --doc/--text must remain executable: %v", err)
}
missingNode := Update
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
@@ -562,19 +1149,19 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{"jobId": "j"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, caller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
}
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "0")
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--export-format", "docx", "--output", "x")
}
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
@@ -596,7 +1183,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "block-1", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
{MediaList, []string{"--node", "n"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
@@ -620,7 +1207,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
args := map[string][]string{
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
"+version-revert": {"--node", "n", "--version", "3", "--yes"},
"+create-from-template": {"--query", "q"},
"+review": {"--node", "n"},
"+media-list": {"--node", "n"},
@@ -676,6 +1263,27 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--dry-run"); err != nil {
t.Fatalf("import to default root failed: %v", err)
}
if err := runDocCoverage(t, Export, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--node", "n", "--output", "out.docx", "--dry-run"); err != nil {
t.Fatalf("export default format failed: %v", err)
}
if selected, ok := sliceUTF16Range("A😀B", 1, 3); !ok || selected != "😀" {
t.Fatalf("UTF-16 selection = %q/%v", selected, ok)
}
if _, ok := sliceUTF16Range("A😀B", 2, 3); ok {
t.Fatal("selection splitting a surrogate pair succeeded")
}
for _, raw := range []string{`["550582"]`, `[550582]`} {
mentions, err := normalizeMentionUserIDs([]string{raw})
if err != nil || len(mentions) != 1 || mentions[0] != "550582" {
t.Fatalf("mention normalization %q = %#v/%v", raw, mentions, err)
}
}
if _, err := normalizeMentionUserIDs([]string{`[{"uid":"550582"}]`}); err == nil {
t.Fatal("object mention unexpectedly succeeded")
}
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
@@ -704,6 +1312,53 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
})
}
func TestCrossPlatformCoverageVersionRoutesAreCanonicalAndHistoryRoutesAreCompatible(t *testing.T) {
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
pairs := map[string]string{
"+history-save": "+version-save",
"+history-list": "+version-list",
"+history-revert": "+version-revert",
}
for compatibility, canonical := range pairs {
compatItem, ok := registered[compatibility]
if !ok {
t.Fatalf("missing compatibility route %s", compatibility)
}
canonicalItem, ok := registered[canonical]
if !ok {
t.Fatalf("missing canonical route %s", canonical)
}
if compatItem.Disposition != shortcut.DispositionAliasInternal || compatItem.PrimaryCommand != canonical {
t.Errorf("%s routing = %s/%s, want alias_internal/%s", compatibility, compatItem.Disposition, compatItem.PrimaryCommand, canonical)
}
if !strings.Contains(strings.Join(compatItem.Contract.Selection.AvoidWhen, " "), canonical) {
t.Errorf("%s compatibility Selection does not route Agent to %s", compatibility, canonical)
}
if canonicalItem.Contract.Selection.UseWhen[0] != canonicalItem.Intent {
t.Errorf("%s UseWhen and Intent drifted", canonical)
}
if strings.Contains(strings.Join(canonicalItem.Contract.Selection.AvoidWhen, " "), "+history-") {
t.Errorf("%s canonical Selection routes back to history compatibility commands", canonical)
}
}
if VersionSave.Command != "+version-save" || VersionSave.Safety.Confirmation != "user_required" {
t.Errorf("version-save command/confirmation = %s/%s", VersionSave.Command, VersionSave.Safety.Confirmation)
}
if compatHistorySave.Safety.Confirmation != "not_required" {
t.Errorf("history-save compatibility confirmation = %s", compatHistorySave.Safety.Confirmation)
}
if VersionRevert.Command != "+version-revert" || VersionRevert.Execute == nil {
t.Errorf("version-revert canonical smart route is incomplete")
}
}
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -713,7 +1368,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -727,7 +1382,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -4,112 +4,165 @@
package doc
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
legacyVersionSave shortcut.Shortcut
legacyVersionList shortcut.Shortcut
legacyVersionRevert shortcut.Shortcut
compatHistorySave shortcut.Shortcut
compatHistoryList shortcut.Shortcut
compatHistoryRevert shortcut.Shortcut
)
func canonicalizeHistoryShortcuts() {
legacyVersionSave = VersionSave
legacyVersionList = VersionList
legacyVersionRevert = VersionRevert
VersionSave.Command = "+history-save"
VersionSave.Command = "+version-save"
VersionSave.Aliases = nil
VersionSave.Description = "手动保存当前文档版本快照"
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
VersionSave.Intent = "当用户要求保存、创建或建立当前文档版本快照时使用;只保存快照,不更新正文。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
VersionSave.Contract = versionSaveContract()
VersionSave.Tips = []string{`dws doc +version-save --node <DOC_ID>`}
VersionList.Command = "+history-list"
VersionList.Command = "+version-list"
VersionList.Aliases = nil
VersionList.Description = "分页列出文档历史版本"
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
VersionList.Contract = versionListContract()
VersionList.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限", Aliases: []string{"page-size"}, AliasesVisible: true},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
VersionList.Tips = []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`}
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if size := rt.IntFirst("page-size", "limit"); size > 0 {
if size := rt.IntFirst("limit", "page-size"); size > 0 {
params["maxResults"] = size
}
if token := rt.StrFirst("page-token", "cursor"); token != "" {
if token := rt.StrFirst("cursor", "page-token"); token != "" {
params["nextCursor"] = token
}
return rt.CallMCP("list_doc_versions", params)
}
VersionRevert.Command = "+history-revert"
VersionRevert.Command = "+version-revert"
VersionRevert.Aliases = nil
VersionRevert.Description = "预检并回滚文档到指定历史版本"
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
VersionRevert.Contract = versionRevertContract()
VersionRevert.Tips = []string{`dws doc +version-revert --node <DOC_ID> --version 3`}
VersionRevert.Execute = executeHistoryRevert
compatHistorySave = compatibilityHistoryShortcut(VersionSave, "+history-save", "+version-save")
compatHistorySave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list")
compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert")
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
TemplateSearch.Description = "按名称检索文档模板"
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。"
TemplateList.Contract = templateListContract()
TemplateSearch.Description = "按名称或关键词检索文档模板"
TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。"
TemplateSearch.Contract = templateSearchContract()
}
func canonicalizeCommentShortcuts() {
// Preserve the historical confirmation contract for comment writes. The
// richer canonical implementations must not silently weaken that gate.
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
CommentCreate.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
func versionSaveContract() corecmd.ContractDecl {
decl := docContract("+version-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +version-save --node <DOC_ID>`})
decl.Selection.AvoidWhen = []string{
"只查看历史版本时使用 doc +version-list;需要恢复历史内容时使用 doc +version-revert",
"需要保存恢复点后继续修改正文时使用 doc +checkpoint-update",
}
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
CommentCreate.Validate = validateCommentCreate
CommentCreate.Execute = executeCommentCreate
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
return decl
}
func versionListContract() corecmd.ContractDecl {
decl := docContract("+version-list", VersionList.Description, VersionList.Intent, []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`})
decl.Selection.AvoidWhen = []string{
"需要创建当前版本快照时使用 doc +version-save",
"已经明确目标版本并要求恢复正文时使用 doc +version-revert",
}
return decl
}
func versionRevertContract() corecmd.ContractDecl {
decl := docContract("+version-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +version-revert --node <DOC_ID> --version 3`})
decl.Selection.AvoidWhen = []string{
"只需要创建当前版本快照时使用 doc +version-save",
"尚未确定目标版本号时先使用 doc +version-list",
}
return decl
}
func compatibilityHistoryShortcut(source shortcut.Shortcut, command, canonical string) shortcut.Shortcut {
result := source
result.Command = command
result.Aliases = nil
result.Description = fmt.Sprintf("兼容入口:%s", source.Description)
result.Intent = fmt.Sprintf("仅当调用方明确使用既有 doc %s 路径时兼容执行。", command)
result.Contract = docContract(command, result.Description, result.Intent, []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)})
result.Contract.Selection.AvoidWhen = []string{fmt.Sprintf("新的 Agent 任务统一使用 doc %s;不要因自然语言版本意图选择本兼容入口", canonical)}
result.Tips = []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)}
if command == "+history-revert" {
result.Contract.Selection.Examples = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
result.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
}
return result
}
func templateSearchContract() corecmd.ContractDecl {
decl := docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
decl.Selection.AvoidWhen = []string{
"没有明确模板名称或关键词、只是浏览可用模板时使用 doc +template-list",
"已经拿到唯一 templateId 时不要重复搜索,直接使用 doc +create-from-template --template-id",
"不要为了预览多个候选而逐个创建文档;多候选必须让用户选择",
}
return decl
}
func templateListContract() corecmd.ContractDecl {
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
decl.Selection.AvoidWhen = []string{
"已经有明确模板名称或关键词时使用 doc +template-search --query",
"已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id",
}
return decl
}
func createFromTemplateContract() corecmd.ContractDecl {
decl := docContract("+create-from-template", "使用已选定的 templateId 创建文档",
"当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`})
decl.Selection.AvoidWhen = []string{
"只有模板名称或关键词时先使用 doc +template-search;零命中或多候选时停止并让用户选择",
"没有模板名称、关键词或 templateId,只想浏览可用模板时使用 doc +template-list",
"不要通过创建多个候选文档来预览模板,也不要反复改写关键词自动重试",
}
return decl
}
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
nodeID := rt.Str("node")
target := rt.Int("version")
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
found, err := findHistoryVersion(rt, nodeID, target)
if err != nil {
return err
}
if !containsVersion(versions, target) {
if !found {
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
}
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
reverted, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target})
if err != nil {
return err
}
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
@@ -125,28 +178,111 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
"version": target, "revertResult": reverted, "current": current, "verified": true,
"verification": "revert_acknowledged_and_document_readable",
},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
const maxPages = 20
cursor := ""
seenCursors := map[string]bool{}
for page := 1; page <= maxPages; page++ {
params := map[string]any{"nodeId": nodeID}
if cursor != "" {
params["nextCursor"] = cursor
}
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", params)
if err != nil {
return false, err
}
if containsVersion(versions, target) {
return true, nil
}
hasMore, hasMoreKnown, nextCursor := docPageState(versions)
nextCursor = strings.TrimSpace(nextCursor)
if hasMoreKnown && !hasMore {
return false, nil
}
if nextCursor == "" {
if hasMoreKnown && hasMore {
return false, historyVersionPaginationError("missing_next_cursor", page, cursor)
}
return false, nil
}
if seenCursors[nextCursor] {
return false, historyVersionPaginationError("stalled_cursor", page, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
return false, historyVersionPaginationError("max_pages", maxPages, cursor)
}
func historyVersionPaginationError(reason string, page int, cursor string) error {
return apperrors.NewAPI(
"文档版本预检无法证明分页已经完整,已停止回滚",
apperrors.WithOperation("doc.history_revert"),
apperrors.WithReason("doc_history_version_"+reason),
apperrors.WithFailureStage("preflight"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithDetails(map[string]any{"page": page, "cursor": cursor, "targetVerified": false}),
)
}
func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if acceptedKeys[normalized] {
if versionNumberMatches(child, target) {
return true
}
}
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
case []any:
for _, child := range typed {
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
}
return false
}
func versionNumberMatches(value any, target int) bool {
switch number := value.(type) {
case float64:
return number == float64(target)
case json.Number:
parsed, err := number.Int64()
return err == nil && parsed == int64(target)
case int:
return number == target
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
return err == nil && parsed == target
}
return false
}
func containsVersion(value any, target int) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
switch number := child.(type) {
case float64:
if int(number) == target && number == float64(target) {
return true
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
if err == nil && parsed == target {
return true
}
if versionNumberMatches(child, target) {
return true
}
}
if containsVersion(child, target) {
@@ -167,39 +303,53 @@ var CreateFromTemplate = shortcut.Shortcut{
Service: "doc",
Command: "+create-from-template",
Product: productDoc,
Description: "按 templateId 直达或搜索消歧后创建文档",
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
Description: "使用已选定的 templateId 创建文档",
Intent: "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
Contract: createFromTemplateContract(),
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
{Name: "query", Type: shortcut.FlagString, Desc: "兼容入口:先搜索且仅唯一命中时创建;新的 Agent 流程应先调用 +template-search"},
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
templateID := rt.Str("template-id")
if templateID == "" {
params := map[string]any{"searchName": rt.Str("query")}
if rt.Str("source") != "" {
params["templateSource"] = rt.Str("source")
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
candidates, err := searchTemplateCandidatesForCreate(rt, rt.Str("query"), rt.Str("source"))
if err != nil {
return err
}
ids := collectTemplateIDs(found)
if len(ids) != 1 {
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
if len(candidates) != 1 {
status := "selection_required"
message := fmt.Sprintf("模板搜索返回 %d 个候选,必须先选择唯一 templateId", len(candidates))
actions := []string{"运行 dws doc +template-search 查看结构化候选", "让用户选择后使用 --template-id 创建一次"}
if len(candidates) == 0 {
status = "not_found"
message = "模板搜索没有命中;已停止创建"
actions = []string{"检查模板来源或请用户提供更准确的模板名", "不要自动变换关键词循环搜索"}
}
return apperrors.NewValidation(message,
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_"+status),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions(actions...),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": status,
"query": rt.Str("query"),
"source": rt.Str("source"),
"candidates": candidates,
}),
)
}
templateID = ids[0]
templateID = candidates[0]["templateId"].(string)
}
params := map[string]any{"templateId": templateID}
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
@@ -212,12 +362,151 @@ var CreateFromTemplate = shortcut.Shortcut{
}
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
if err != nil {
return err
return docUnknownWriteError("doc.create_from_template", "apply_template", "", err)
}
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"templateId": templateID, "result": result}, map[string]any{"name": "apply_template", "status": "success"}))
},
}
func searchTemplateCandidatesForCreate(rt *shortcut.RuntimeContext, query, source string) ([]map[string]any, error) {
const (
pageSize = 2
maxPages = 20
)
cursor := ""
seenCursors := map[string]bool{}
seenCandidates := map[string]bool{}
candidates := make([]map[string]any, 0, pageSize)
for page := 1; page <= maxPages; page++ {
params := map[string]any{"searchName": query, "maxResults": pageSize}
if source != "" {
params["templateSource"] = source
}
if cursor != "" {
params["nextCursor"] = cursor
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return nil, err
}
pageCandidates := collectTemplateCandidates(found)
for _, candidate := range pageCandidates {
id, _ := candidate["templateId"].(string)
if id == "" || seenCandidates[id] {
continue
}
seenCandidates[id] = true
candidates = append(candidates, candidate)
}
hasMore, hasMoreKnown, next := docPageState(found)
next = strings.TrimSpace(next)
if len(candidates) > 1 {
return sortedTemplateCandidates(candidates), nil
}
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && next == "" && len(pageCandidates) < pageSize {
complete = true
}
if complete {
return sortedTemplateCandidates(candidates), nil
}
if next == "" {
return nil, templatePaginationError("missing_next_cursor", page, cursor, candidates)
}
if next == cursor || seenCursors[next] {
return nil, templatePaginationError("stalled_cursor", page, next, candidates)
}
seenCursors[next] = true
cursor = next
}
return nil, templatePaginationError("max_pages", maxPages, cursor, candidates)
}
func templatePaginationError(reason string, page int, cursor string, candidates []map[string]any) error {
return apperrors.NewAPI(
"模板搜索分页未完整结束;为避免基于非唯一候选创建文档,已停止执行",
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_pagination_"+reason),
apperrors.WithFailureStage("template_search"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions("使用 dws doc +template-search 分页查看候选", "选择唯一 templateId 后重新执行创建"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": "pagination_incomplete",
"reason": reason,
"page": page,
"nextCursor": cursor,
"candidates": sortedTemplateCandidates(candidates),
}),
)
}
func sortedTemplateCandidates(candidates []map[string]any) []map[string]any {
out := append([]map[string]any(nil), candidates...)
sort.SliceStable(out, func(i, j int) bool {
leftName, _ := out[i]["name"].(string)
rightName, _ := out[j]["name"].(string)
if leftName != rightName {
return leftName < rightName
}
leftID, _ := out[i]["templateId"].(string)
rightID, _ := out[j]["templateId"].(string)
return leftID < rightID
})
return out
}
func collectTemplateCandidates(value any) []map[string]any {
seen := map[string]bool{}
var out []map[string]any
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
id := directTemplateString(typed, "templateId", "template_id")
if id != "" && !seen[id] {
seen[id] = true
candidate := map[string]any{"templateId": id}
if name := directTemplateString(typed, "templateName", "name", "title"); name != "" {
candidate["name"] = name
}
if source := directTemplateString(typed, "templateSource", "source"); source != "" {
candidate["source"] = source
}
if description := directTemplateString(typed, "description", "summary"); description != "" {
candidate["description"] = description
}
out = append(out, candidate)
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
walk(typed[key])
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(value)
return sortedTemplateCandidates(out)
}
func directTemplateString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func collectTemplateIDs(value any) []string {
seen := map[string]bool{}
var out []string
+61 -20
View File
@@ -17,11 +17,11 @@ import (
var MediaList = shortcut.Shortcut{
Service: "doc", Command: "+media-list", Product: productDoc,
Description: "列出文档正文中的图片和附件资源",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
[]string{`dws doc +media-list --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
@@ -31,40 +31,42 @@ var MediaList = shortcut.Shortcut{
return err
}
items := collectMediaItems(data)
return rt.Output(map[string]any{"count": len(items), "media": items})
return rt.Output(docEnvelope("doc.media_list", map[string]any{"nodeId": rt.Str("node"), "count": len(items), "media": items}))
},
}
var MediaInsert = shortcut.Shortcut{
Service: "doc", Command: "+media-insert", Product: productDoc,
Description: "上传本地图片或文件并插入文档正文",
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
Intent: "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
"当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在的相对文件路径", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内存在且不能经符号链接逃逸的相对文件"}},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
}
var MediaDownload = shortcut.Shortcut{
Service: "doc", Command: "+media-download", Product: productDoc,
Description: "安全下载文档正文附件到工作目录",
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
Intent: "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
"当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
@@ -97,7 +99,7 @@ var MediaPreview = shortcut.Shortcut{
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
dir, err := docMkdirTemp("", "dws-doc-preview-*")
if err != nil {
@@ -106,9 +108,13 @@ var MediaPreview = shortcut.Shortcut{
result, err := downloadResolvedResource(rt, data, dir, ".")
if err != nil {
_ = docRemoveAll(dir)
return err
return docMediaRecoveryError("doc_media_preview_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
_ = docRemoveAll(dir)
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes, "verified": true}))
},
}
@@ -124,11 +130,20 @@ var ResourceUpdate = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在封面图片的相对路径"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("file") == "" {
return nil
}
return validateWorkspaceInputPath("file", rt.Str("file"))
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "提供 --file 时必须是工作目录内已存在且不通过符号链接逃逸的相对路径"},
},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
}
var ResourceDownload = shortcut.Shortcut{
@@ -225,7 +240,7 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
cwd, err := docGetwd()
if err != nil {
@@ -233,9 +248,35 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
return docMediaRecoveryError("doc_media_download_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": true}))
}
func docMediaRecoveryError(reason, stage, nodeID, resourceID string, cause error) error {
return apperrors.NewAPI(
"文档媒体操作未完成;已保留 nodeId/resourceId,请稍后重试同一 shortcut,禁止改用临时 URL 或手写 HTTP",
apperrors.WithOperation("doc.media_download"),
apperrors.WithReason(reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions(
fmt.Sprintf("dws doc +media-download --node %s --resource-id %s --output <工作目录相对路径>", nodeID, resourceID),
"不要 curl/wget 临时 OSS URL,不要安装本地下载或文档转换依赖",
),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1",
"status": "incomplete",
"nodeId": nodeID,
"resourceId": resourceID,
"stage": stage,
}),
apperrors.WithCause(cause),
)
}
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
type docPageOptions struct {
PageAll bool
PageSize int
MaxPages int
MaxItems int
Cursor string
}
func collectDocPages(
rt *shortcut.RuntimeContext,
tool, outputKey string,
base map[string]any,
project func(map[string]any) []map[string]any,
options docPageOptions,
) (map[string]any, error) {
if options.PageSize <= 0 {
options.PageSize = 30
}
if options.MaxPages <= 0 {
options.MaxPages = 20
}
if options.MaxItems <= 0 {
options.MaxItems = 500
}
pageLimit := 1
if options.PageAll {
pageLimit = options.MaxPages
}
items := make([]map[string]any, 0)
seenItems := map[string]bool{}
seenCursors := map[string]bool{}
cursor := strings.TrimSpace(options.Cursor)
if cursor != "" {
seenCursors[cursor] = true
}
complete := false
truncated := false
stopReason := ""
nextCursor := ""
hasMore := false
pagesRead := 0
for page := 1; page <= pageLimit; page++ {
remaining := options.MaxItems - len(items)
requestPageSize := options.PageSize
if requestPageSize > remaining {
requestPageSize = remaining
}
params := cloneMap(base)
params["pageSize"] = requestPageSize
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData(productDoc, tool, params)
if err != nil {
return nil, docPaginationError(tool, "page_read_failed", err, page, items, cursor)
}
pagesRead++
projected := project(data)
pageItems := make([]map[string]any, 0, len(projected))
pageSeen := map[string]bool{}
for _, item := range projected {
key := pageItemKey(item)
if key != "" && (seenItems[key] || pageSeen[key]) {
continue
}
if key != "" {
pageSeen[key] = true
}
pageItems = append(pageItems, item)
}
if len(pageItems) > remaining {
return nil, docPaginationError(tool, "page_size_exceeded", nil, page, items, cursor)
}
for _, item := range pageItems {
if key := pageItemKey(item); key != "" {
seenItems[key] = true
}
items = append(items, item)
}
pageHasMore, hasMoreKnown, next := docPageState(data)
nextCursor = strings.TrimSpace(next)
switch {
case hasMoreKnown:
hasMore = pageHasMore
complete = !pageHasMore
case nextCursor != "":
hasMore = true
case len(projected) < requestPageSize:
complete = true
default:
hasMore = true
stopReason = "pagination_unproven"
}
if len(items) >= options.MaxItems && hasMore {
truncated = true
stopReason = "max_items"
}
if truncated {
complete = false
hasMore = true
}
if truncated || complete || !options.PageAll {
break
}
if stopReason == "pagination_unproven" {
return nil, docPaginationError(tool, stopReason, nil, page, items, cursor)
}
if nextCursor == "" {
return nil, docPaginationError(tool, "missing_next_cursor", nil, page, items, cursor)
}
if seenCursors[nextCursor] {
return nil, docPaginationError(tool, "stalled_cursor", nil, page, items, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
if page == pageLimit {
truncated = true
stopReason = "max_pages"
}
}
result := map[string]any{
"contractVersion": "doc.list.v1",
"status": "success",
"count": len(items),
outputKey: items,
"pagesRead": pagesRead,
"complete": complete,
"truncated": truncated,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"failures": []map[string]any{},
}
return result, nil
}
func docPaginationError(tool, reason string, cause error, page int, items []map[string]any, cursor string) error {
message := fmt.Sprintf("%s 分页未完成,已在第 %d 页停止", tool, page)
return apperrors.NewAPI(
message,
apperrors.WithOperation("doc/"+tool),
apperrors.WithReason("doc_pagination_"+reason),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(cause != nil),
apperrors.WithActions("保留 nextCursor 后继续读取", "缩小查询范围后重试"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.list.v1",
"status": "partial_success",
"complete": false,
"reason": reason,
"page": page,
"nextCursor": cursor,
"count": len(items),
"items": items,
}),
apperrors.WithCause(cause),
)
}
func cloneMap(source map[string]any) map[string]any {
out := make(map[string]any, len(source)+2)
for key, value := range source {
out[key] = value
}
return out
}
func pageItemKey(item map[string]any) string {
for _, key := range []string{"nodeId", "id", "url"} {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
return key + ":" + strings.TrimSpace(value)
}
}
return ""
}
func docPageState(data map[string]any) (bool, bool, string) {
var hasMore bool
var known bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !known {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, known = flag, true
break
}
}
}
if next == "" {
if value, ok := docFirst(value, "nextPageToken", "nextCursor", "next_page_token"); ok {
next, _ = value.(string)
}
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, known, next
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "b", "name": "B"}, map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_nodes": {
{"nodes": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"},
{"nodes": []any{map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
},
}}
err := runDocCoverage(t, List, caller, "--folder", "f", "--page-all", "--limit", "1")
if err == nil || len(caller.history) != 2 {
t.Fatalf("stalled pagination err=%v history=%#v", err, caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("max-items pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}},
"hasMore": true, "nextPageToken": "p2",
}},
}}
err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_pagination_page_size_exceeded" {
t.Fatalf("page overflow error = %#v", err)
}
if len(caller.history) != 1 || caller.history[0].params["pageSize"] != 1 {
t.Fatalf("page overflow calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) {
run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) {
t.Helper()
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
if err == nil {
err = rt.Output(result)
}
return err
}
err := runDocCoverage(t, declaration, caller)
return nil, err
}
defaults := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{"documents": []any{map[string]any{"url": "u"}}}},
}}
if _, err := run(t, defaults, docPageOptions{Cursor: " initial "}); err != nil {
t.Fatal(err)
}
if len(defaults.history) != 1 || defaults.history[0].params["pageSize"] != 30 || defaults.history[0].params["pageToken"] != "initial" {
t.Fatalf("default pagination call = %#v", defaults.history)
}
readFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
if _, err := run(t, readFailure, docPageOptions{PageAll: true}); err == nil {
t.Fatal("page read failure succeeded")
}
duplicate := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "a"}, map[string]any{"name": "no-key"},
},
"nextPageToken": "p2",
}},
}}
if _, err := run(t, duplicate, docPageOptions{PageAll: false, PageSize: 3, MaxPages: 2, MaxItems: 10}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
response map[string]any
maxPages int
wantError bool
}{
{"unproven", map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}}, 2, true},
{"missing cursor", map[string]any{"documents": []any{}, "hasMore": true}, 2, true},
{"max pages", map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": "p2"}, 1, false},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}}
_, err := run(t, caller, docPageOptions{PageAll: true, PageSize: 1, MaxPages: tc.maxPages, MaxItems: 10})
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if got := pageItemKey(map[string]any{"url": " u "}); got != "url:u" {
t.Fatalf("URL page key = %q", got)
}
if got := pageItemKey(map[string]any{"id": 1}); got != "" {
t.Fatalf("invalid page key = %q", got)
}
if more, known, next := docPageState(map[string]any{"data": map[string]any{"has_more": true, "nextCursor": "c"}}); !more || !known || next != "c" {
t.Fatalf("nested page state = %v/%v/%q", more, known, next)
}
}
+82 -9
View File
@@ -4,6 +4,7 @@
package doc
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf16"
@@ -60,13 +61,17 @@ var CommentUpdate = shortcut.Shortcut{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("update_comment", params)
},
@@ -113,14 +118,29 @@ func validateCommentCreate(rt *shortcut.RuntimeContext) error {
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
if rt.Str("block-id") != "" {
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
if rt.Str("selected-text") != "" {
params["selectedText"] = rt.Str("selected-text")
blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end")
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"})
if err != nil {
return err
}
return rt.CallMCP("create_inline_comment", params)
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
selectedText, ok := sliceUTF16Range(blockText[blockID], start, end)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end))
}
if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText {
return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致")
}
params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
@@ -136,9 +156,62 @@ func executeCommentCreate(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return rt.CallMCP("create_inline_comment", params)
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
return rt.CallMCP("create_comment", params)
return executeCommentWrite(rt, "create_comment", "global", params)
}
func normalizeMentionUserIDs(values []string) ([]string, error) {
if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") {
var decoded []any
decoder := json.NewDecoder(strings.NewReader(values[0]))
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err))
}
values = make([]string, 0, len(decoded))
for _, value := range decoded {
switch typed := value.(type) {
case string:
values = append(values, typed)
case json.Number:
values = append(values, typed.String())
default:
return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid")
}
}
}
values = stringSliceNonEmpty(values)
if len(values) == 0 {
return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid")
}
return values, nil
}
func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error {
result, err := rt.CallMCPWriteData(productComment, tool, params)
if err != nil {
return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.comment_create", map[string]any{
"nodeId": rt.Str("node"), "commentType": commentType, "result": result,
}, map[string]any{"name": tool, "status": "success"}))
}
func sliceUTF16Range(text string, start, end int) (string, bool) {
units := utf16.Encode([]rune(text))
if start < 0 || end <= start || end > len(units) {
return "", false
}
isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF }
isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF }
if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) {
return "", false
}
if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) {
return "", false
}
return string(utf16.Decode(units[start:end])), true
}
type selectionMatch struct {
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package docresolver resolves document IDs, URLs, and natural titles to one
// typed document target before a shortcut performs a business operation.
package docresolver
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
searchPageSize = 30
searchPageLimit = 40
)
// Reader is the read-only RuntimeContext surface needed by the resolver.
type Reader interface {
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
}
// Candidate is a credential-free document search candidate.
type Candidate struct {
CanonicalID string `json:"canonicalId"`
Name string `json:"name,omitempty"`
Product string `json:"product"`
ResourceType string `json:"resourceType,omitempty"`
URL string `json:"canonicalUrl,omitempty"`
ContainerID string `json:"containerId,omitempty"`
}
// Resolution is the stable result consumed by document shortcuts.
type Resolution struct {
ContractVersion string `json:"contractVersion"`
Status string `json:"status"`
Query string `json:"query,omitempty"`
MatchedBy string `json:"matchedBy"`
Complete bool `json:"complete"`
Selected Candidate `json:"selected"`
Candidates []Candidate `json:"candidates"`
}
// Resolve accepts exactly one stable node/URL or natural title. Natural title
// resolution exhausts pagination before selecting a unique candidate.
func Resolve(rt Reader, node, query string) (Resolution, error) {
node = strings.TrimSpace(node)
query = strings.TrimSpace(query)
if (node == "") == (query == "") {
return Resolution{}, apperrors.NewValidation(
"文档目标必须且只能提供一个:--node 或 --query",
apperrors.WithReason("document_target_invalid"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
)
}
if node != "" {
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
MatchedBy: "stable_id_or_url",
Complete: true,
Selected: Candidate{
CanonicalID: node,
Product: "doc",
},
Candidates: []Candidate{},
}, nil
}
candidates, err := searchAll(rt, query)
if err != nil {
return Resolution{}, err
}
selected, matchedBy := selectCandidates(candidates, query)
if len(selected) == 0 {
return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档")
}
if len(selected) > 1 {
return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧")
}
if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" {
return Resolution{}, apperrors.NewValidation(
fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType),
apperrors.WithReason("document_target_type_mismatch"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}),
)
}
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
Query: query,
MatchedBy: matchedBy,
Complete: true,
Selected: selected[0],
Candidates: []Candidate{},
}, nil
}
func searchAll(rt Reader, query string) ([]Candidate, error) {
cursor := ""
seenCursors := map[string]bool{}
all := make([]Candidate, 0)
for page := 1; page <= searchPageLimit; page++ {
params := map[string]any{"keyword": query, "pageSize": searchPageSize}
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData("doc", "search_documents", params)
if err != nil {
return nil, err
}
rows := extractRows(data)
all = append(all, extractCandidates(rows)...)
hasMore, hasMoreKnown, next := extractPage(data)
switch {
case hasMoreKnown && !hasMore:
return dedupe(all), nil
case hasMoreKnown && hasMore:
// next is validated below.
case next != "":
// Older responses may only publish the continuation.
case len(rows) < searchPageSize:
return dedupe(all), nil
default:
return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标")
}
next = strings.TrimSpace(next)
if next == "" {
return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标")
}
if next == cursor || seenCursors[next] {
return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next))
}
seenCursors[next] = true
cursor = next
}
return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit))
}
func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) {
exact := make([]Candidate, 0)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
exact = append(exact, candidate)
}
}
if len(exact) > 0 {
return exact, "exact_title"
}
return candidates, "search_candidate"
}
func extractCandidates(rows []any) []Candidate {
out := make([]Candidate, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
continue
}
candidate := Candidate{
CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"),
Name: firstString(item, "name", "title", "docName", "fileName"),
Product: "doc",
ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"),
URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"),
ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"),
}
if candidate.CanonicalID != "" {
out = append(out, candidate)
}
}
return out
}
func dedupe(in []Candidate) []Candidate {
seen := make(map[string]bool, len(in))
out := make([]Candidate, 0, len(in))
for _, candidate := range in {
if candidate.CanonicalID == "" || seen[candidate.CanonicalID] {
continue
}
seen[candidate.CanonicalID] = true
out = append(out, candidate)
}
return out
}
func extractRows(data map[string]any) []any {
for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} {
value, ok := data[key]
if !ok {
continue
}
if rows, ok := value.([]any); ok {
return rows
}
if nested, ok := value.(map[string]any); ok {
if rows := extractRows(nested); rows != nil {
return rows
}
}
}
return []any{}
}
func extractPage(data map[string]any) (bool, bool, string) {
var hasMore bool
var hasMoreKnown bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !hasMoreKnown {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, hasMoreKnown = flag, true
break
}
}
}
if next == "" {
next = firstString(value, "nextPageToken", "nextCursor", "next_page_token")
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, hasMoreKnown, next
}
func firstString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func normalizeType(value string) string {
return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, ".")))
}
func resolutionError(status, query string, candidates []Candidate, message string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason("document_target_"+status),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": status,
"query": query,
"complete": true,
"candidates": candidates,
}),
)
}
func incompleteError(query string, candidates []Candidate, cause string) error {
return apperrors.NewAPI(
"无法证明文档搜索候选完整,已停止目标解析",
apperrors.WithReason("document_target_incomplete"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": "incomplete",
"query": query,
"complete": false,
"cause": cause,
"candidates": dedupe(candidates),
}),
)
}
@@ -0,0 +1,154 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package docresolver
import (
"errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type scriptedReader struct {
pages []map[string]any
err error
calls int
}
func (r *scriptedReader) CallMCPData(_ string, _ string, _ map[string]any) (map[string]any, error) {
r.calls++
if r.err != nil {
return nil, r.err
}
if len(r.pages) == 0 {
return map[string]any{}, nil
}
page := r.pages[0]
r.pages = r.pages[1:]
return page, nil
}
func row(id, name, typ string) any {
return map[string]any{"nodeId": id, "name": name, "docType": typ, "url": "https://alidocs.dingtalk.com/i/nodes/" + id}
}
func TestCrossPlatformCoverageResolveStableTargetDoesNotSearch(t *testing.T) {
reader := &scriptedReader{}
resolution, err := Resolve(reader, "node-1", "")
if err != nil || reader.calls != 0 || resolution.Selected.CanonicalID != "node-1" || !resolution.Complete {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveNaturalTitleExhaustsPagesBeforeSelecting(t *testing.T) {
first := make([]any, 0, searchPageSize)
for i := 0; i < searchPageSize; i++ {
first = append(first, row(fmt.Sprintf("other-%d", i), "其他", "adoc"))
}
reader := &scriptedReader{pages: []map[string]any{
{"documents": first, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{row("wanted", "项目周报", "adoc")}, "hasMore": false},
}}
resolution, err := Resolve(reader, "", "项目周报")
if err != nil || reader.calls != 2 || resolution.Selected.CanonicalID != "wanted" || resolution.MatchedBy != "exact_title" {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveFailsClosedForAmbiguousIncompleteAndWrongType(t *testing.T) {
tests := []struct {
name string
pages []map[string]any
query string
reason string
}{
{"ambiguous", []map[string]any{{"documents": []any{row("1", "周报", "adoc"), row("2", "周报", "adoc")}, "hasMore": false}}, "周报", "ambiguous"},
{"not found", []map[string]any{{"documents": []any{}, "hasMore": false}}, "missing", "not_found"},
{"stalled", []map[string]any{{"documents": []any{row("1", "周报", "adoc")}, "hasMore": true, "nextPageToken": "p1"}, {"documents": []any{}, "hasMore": true, "nextPageToken": "p1"}}, "周报", "incomplete"},
{"wrong type", []map[string]any{{"documents": []any{row("1", "周报", "pdf")}, "hasMore": false}}, "周报", "type_mismatch"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Resolve(&scriptedReader{pages: tc.pages}, "", tc.query)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || !strings.Contains(typed.Reason, tc.reason) {
t.Fatalf("err=%v", err)
}
})
}
reader := &scriptedReader{err: errors.New("backend")}
if _, err := Resolve(reader, "", "x"); !errors.Is(err, reader.err) {
t.Fatalf("transport error=%v", err)
}
}
func TestCrossPlatformCoverageResolverFinalBranchMatrix(t *testing.T) {
for _, target := range [][2]string{{"", ""}, {"node", "query"}} {
if _, err := Resolve(&scriptedReader{}, target[0], target[1]); err == nil {
t.Fatalf("invalid target %#v succeeded", target)
}
}
full := make([]any, searchPageSize)
for i := range full {
full[i] = row(fmt.Sprintf("full-%d", i), "other", "adoc")
}
for _, tc := range []struct {
name string
pages []map[string]any
}{
{"unproven full page", []map[string]any{{"documents": full}}},
{"missing cursor", []map[string]any{{"documents": []any{}, "hasMore": true}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Resolve(&scriptedReader{pages: tc.pages}, "", "wanted"); err == nil {
t.Fatal("incomplete pagination succeeded")
}
})
}
if _, err := Resolve(&scriptedReader{pages: []map[string]any{{"documents": []any{}}}}, "", "wanted"); err == nil {
t.Fatal("underfilled implicit final page should resolve to not-found")
}
pages := make([]map[string]any, searchPageLimit)
for i := range pages {
pages[i] = map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": fmt.Sprintf("p-%d", i+1)}
}
if _, err := Resolve(&scriptedReader{pages: pages}, "", "wanted"); err == nil {
t.Fatal("max-page pagination succeeded")
}
nested := map[string]any{
"result": map[string]any{
"data": map[string]any{
"records": []any{
"invalid",
map[string]any{"id": "", "title": "empty"},
map[string]any{"doc_id": "d", "fileName": "Doc", "fileType": ".ADOC", "webUrl": " u ", "parentId": "p"},
},
"pagination": map[string]any{"has_more": false, "next_page_token": " next "},
},
},
}
rows := extractRows(nested)
candidates := extractCandidates(rows)
if len(candidates) != 1 || candidates[0].CanonicalID != "d" || normalizeType(candidates[0].ResourceType) != "adoc" {
t.Fatalf("nested candidates = %#v", candidates)
}
if hasMore, known, next := extractPage(nested); !known || hasMore || next != "next" {
t.Fatalf("nested page = %v/%v/%q", hasMore, known, next)
}
if rows := extractRows(map[string]any{"items": "not-a-list"}); len(rows) != 0 {
t.Fatalf("non-list rows = %#v", rows)
}
deduped := dedupe([]Candidate{{}, {CanonicalID: "d"}, {CanonicalID: "d"}})
if len(deduped) != 1 {
t.Fatalf("deduped = %#v", deduped)
}
selected, matchedBy := selectCandidates([]Candidate{{CanonicalID: "d", Name: "Different"}}, "query")
if len(selected) != 1 || matchedBy != "search_candidate" {
t.Fatalf("fallback selection = %#v/%q", selected, matchedBy)
}
}
+14
View File
@@ -153,6 +153,20 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
return rt.callMCPData(product, tool, params)
}
// CallMCPReadData dispatches a tool whose name is explicitly classified as
// read-only without consulting Cobra state. Callers must invoke it serially
// unless the injected ToolCaller separately documents and enforces concurrent
// safety. Write-shaped tool names fail closed.
func (rt *RuntimeContext) CallMCPReadData(product, tool string, params map[string]any) (map[string]any, error) {
if !looksReadTool(tool) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"并发只读入口拒绝写工具 %s/%s;写操作必须使用 CallMCPWriteData",
product, tool,
))
}
return rt.callMCPReadData(product, tool, params)
}
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
// accidentally perform writes while rendering a preview. For compatibility with
+8 -8
View File
@@ -11,11 +11,11 @@
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+version-save": {"disposition":"semantic_adapter","semantic_delta":"版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。","risk":"write","public":true,"reviewed":true},
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。","risk":"read","public":true,"reviewed":true},
"+version-revert": {"disposition":"primary_smart","semantic_delta":"版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称或关键词检索模板并返回可消歧候选和 templateId。","risk":"read","public":true,"reviewed":true},
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
@@ -40,9 +40,9 @@
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
"+history-save": {"disposition":"alias_internal","semantic_delta":"保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。","risk":"write","primary":"+version-save","public":true,"reviewed":true},
"+history-list": {"disposition":"alias_internal","semantic_delta":"保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。","risk":"read","primary":"+version-list","public":true,"reviewed":true},
"+history-revert": {"disposition":"alias_internal","semantic_delta":"保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。","risk":"high-risk-write","primary":"+version-revert","public":true,"reviewed":true},
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
@@ -50,7 +50,7 @@
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"仅兼容既有调用;selection 与 block/start/end 新场景都统一使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
}
}
+8
View File
@@ -685,6 +685,14 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
}
}
func TestCrossPlatformCoverageCallMCPReadDataRejectsWriteName(t *testing.T) {
rt := &RuntimeContext{}
_, err := rt.CallMCPReadData("doc", "update_document", map[string]any{"nodeId": "n"})
if err == nil || !strings.Contains(err.Error(), "doc/update_document") {
t.Fatalf("read-only guard error = %v", err)
}
}
func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
+15 -3
View File
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
COMPACT_PRODUCT_SERVICES = {"chat", "doc"}
def md_escape(value: Any) -> str:
@@ -121,7 +121,10 @@ def runtime_contract_block() -> str:
def mono_overview(items: list[dict[str, Any]]) -> str:
counts = Counter(item["service"] for item in items)
# The source collector cannot see a small number of commands whose final
# canonical names are normalized at runtime. Overview counts come from the
# reviewed public catalog so they cannot under-report those declarations.
counts = Counter(service for service, _ in load_public_catalog())
rows = []
for service, count in sorted(counts.items()):
path = SERVICE_TO_SKILL.get(service)
@@ -163,10 +166,18 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
# Compact skills intentionally do not depend on the source parser's ability
# to recover every runtime-normalized declaration. The reviewed public
# catalog is the count authority for this non-enumerating overview.
public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service)
if service == "doc":
discovery = """已知意图按下方路由。"""
else:
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {public_count} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。{discovery}
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
@@ -194,6 +205,7 @@ def update_runtime_contract(check: bool) -> list[Path]:
changed = []
targets = [
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
SHARED_SKILL,
]
for path in targets:
+85 -1
View File
@@ -7,12 +7,14 @@ cd "$ROOT"
python3 scripts/gen_skill_shortcut_sections.py --check
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
doc_skill="skills/multi/dingtalk-doc/SKILL.md"
event_skill="skills/multi/dingtalk-misc/references/event.md"
mono_skill="skills/mono/SKILL.md"
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
doc_max_bytes=9000
event_max_bytes=10000
runtime_contract_max_bytes=3000
@@ -23,6 +25,13 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
exit 1
fi
doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')"
if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2
exit 1
fi
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
printf '%s\n' \
@@ -51,6 +60,20 @@ if [ "$shortcut_rows" -ne 0 ]; then
exit 1
fi
doc_shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\| `dws doc \+/ { count++ }
END { print count + 0 }
' "$doc_skill"
)"
if [ "$doc_shortcut_rows" -ne 0 ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill re-expanded $doc_shortcut_rows shortcut rows" >&2
exit 1
fi
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
@@ -65,6 +88,67 @@ do
fi
done
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
"## 关键结果语义" \
"## 按需加载" \
"## 错误最短路径"
do
if ! grep -Fq "$required_heading" "$doc_skill"; then
printf '%s\n' \
"skill value regression: $doc_skill is missing $required_heading" >&2
exit 1
fi
done
for required_route in \
"+search" \
"+fetch" \
"+create" \
"+update" \
"+checkpoint-update" \
"+export"
do
if ! grep -Fq -- "$required_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill is missing $required_route" >&2
exit 1
fi
done
for forbidden_route in \
"## 标准 SOP" \
"doc_create_and_write.py" \
"只在任务命中时读取一个精确 reference" \
"MUST 先用 Read" \
"先用 Read 工具完整读取"
do
if grep -Fq "$forbidden_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill restored legacy route: $forbidden_route" >&2
exit 1
fi
done
for required_reference_rule in \
"Golden Route 已给出命令且参数足够时,禁止读取 reference" \
"才最多读取一个 reference" \
"\`+create\`、\`+fetch\`、\`+update\` append/overwrite、\`+export\`、\`+import\` 禁止读取 reference"
do
if ! grep -Fq "$required_reference_rule" "$doc_skill"; then
printf '%s\n' \
"skill reference budget regression: $doc_skill is missing $required_reference_rule" >&2
exit 1
fi
done
if grep -Fq "../dingtalk-shared/SKILL.md" "$doc_skill"; then
printf '%s\n' \
"skill context regression: $doc_skill requires full dingtalk-shared cold-start loading" >&2
exit 1
fi
for required_route in \
"+dm" \
"+send-to-group" \
@@ -108,4 +192,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)"
+135 -2
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"os"
"reflect"
"sort"
"strings"
)
@@ -510,7 +511,8 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}
if oldTool.Constraints != newTool.Constraints &&
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
!compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
}
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
@@ -562,6 +564,31 @@ var reviewedInterfaceRefRedirect = map[string]map[string]string{
},
}
// reviewedConstraintTransition enumerates exact constraint changes that were
// explicitly reviewed even though the compatibility gate cannot infer their
// business semantics. Keep this narrow: removing a constraint can expose a new
// runtime route, so arbitrary removals must not pass as harmless drift.
var reviewedConstraintTransition = map[string]map[string]string{
// PR #933 aligned the Shortcut and Schema with the existing doc import
// runtime: omitting both targets imports into the default root. Keeping the
// historical require_one_of made the documented Golden Route unreachable.
"doc/doc.shortcut_import": {
`{"require_one_of":[["folder","workspace"]]}`: "",
},
}
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
transitions, ok := reviewedConstraintTransition[toolPath]
if !ok {
return false
}
want, ok := transitions[oldTool.Constraints]
if !ok {
return false
}
return want == newTool.Constraints
}
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
// backing RPC when the migration is an explicitly reviewed entry in
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
@@ -814,14 +841,120 @@ func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bo
return true
}
// parameterTypeChange identifies one published parameter type migration
// exactly: which tool, which parameter, and in which direction. Nothing is
// matched by wildcard — an entry differing in any of the four fields does not
// apply.
//
// ToolPath is "<product id>/<tool id>" exactly as the comparison builds it (see
// where toolPath is assembled), e.g.
// "minutes/minutes.apply_minutes_permission". From and To are the canonical
// type values schemaType produces — the JSON encoding of the "type" keyword,
// so `"string"` **with** its quotes, not a bare string. Spelling either one any
// other way silently disables the exemption instead of failing loudly (exactly
// how reviewedInterfaceRefRedirect was broken twice), so
// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical
// recomputes both through schemaType rather than trusting the table.
type parameterTypeChange struct {
ToolPath string
Parameter string
From string
To string
}
// reviewedParameterTypeChanges enumerates the individually reviewed parameter
// type migrations this gate accepts. A published type is part of the Schema
// contract and a swap cannot be proven safe from the type names alone, so a
// migration is accepted only when this exact tool, parameter and old→new pair
// appear here. Adding an entry is a contract decision and belongs in review,
// not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "integer" is a
// separate key from "integer" → "string", and only the reviewed direction is
// accepted.
//
// The table alone does not admit a change: the migration is rejected unless
// every other published field of the parameter is identical — not merely free
// of compatibility failures. See compatibleReviewedParameterTypeChange.
var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag to a
// native Int flag, so the published type follows it from "string" to
// "integer". A parameter's type is projected from the Cobra flag type
// (provenance source cobra_flag_type), so it describes how the CLI accepts
// the value, not how the backing RPC receives it.
//
// Accepted because no historical caller is invalidated:
//
// - Consumers read this Schema to construct CLI invocations, and a command
// line is text either way: "--policy 4" is the same argv under both
// declarations, and a quoted "--policy \"4\"" still reaches pflag as 4.
// - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's
// base-0 parse accepts every base-10 spelling the previous
// strconv.ParseInt(v, 10, 64) accepted, so the set of successful
// invocations only widens.
// - "integer" also matches what actually goes on the wire: this parameter
// maps to property "policyId", which the command has always sent as a
// number, so the new declaration is closer to the request than the old.
//
// The parameter's default stays absent on both sides here (a zero default is
// not published) and every other published field is identical, which is why
// the equality guard admits this entry. A migration that also moved the
// default — or relaxed required, cleared required_when, widened enum, cleared
// interface_type — would be rejected even though several of those are
// individually compatible, because none of them were reviewed under this
// entry.
{
ToolPath: "minutes/minutes.apply_minutes_permission",
Parameter: "policy",
From: `"string"`,
To: `"integer"`,
}: {},
}
// parameterContractUnchangedExceptType reports whether every published field of
// the parameter other than its type is identical.
//
// This is a full equality check rather than "the gate recorded no other
// failure", because several field changes are individually *compatible* and so
// produce no failure at all: relaxing required or cli_required, clearing
// required_when, widening enum, clearing interface_type, and clearing property
// through a reviewed mapping exclusion. Keying the carve-out on the failure list
// would let any of those ride along with a reviewed type migration without ever
// having been reviewed under that entry — the exemption would be wider than what
// it documents.
//
// Comparing the struct also means a field added to parameterSchema later is
// covered here automatically, instead of silently widening every existing entry.
func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool {
oldParameter.Type = ""
newParameter.Type = ""
return reflect.DeepEqual(oldParameter, newParameter)
}
// compatibleReviewedParameterTypeChange accepts a published parameter type
// migration when it is an explicitly reviewed entry **and** the rest of the
// parameter's published contract is unchanged.
func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool {
_, reviewed := reviewedParameterTypeChanges[parameterTypeChange{
ToolPath: toolPath,
Parameter: name,
From: oldParameter.Type,
To: newParameter.Type,
}]
return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter)
}
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
var failures []string
if oldParameter.Type != newParameter.Type &&
!compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name))
}
for _, field := range []struct {
name string
old string
new string
}{
{name: "type", old: oldParameter.Type, new: newParameter.Type},
{name: "default", old: oldParameter.Default, new: newParameter.Default},
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
{name: "format", old: oldParameter.Format, new: newParameter.Format},
+264
View File
@@ -430,6 +430,239 @@ func TestMergeContracts(t *testing.T) {
}
}
// registerReviewedParameterTypeFixture puts a fixture entry in the reviewed
// table for the duration of one test, so the behaviour tests exercise the real
// lookup instead of depending on whichever production entries happen to exist.
func registerReviewedParameterTypeFixture(t *testing.T, change parameterTypeChange) {
t.Helper()
if _, exists := reviewedParameterTypeChanges[change]; exists {
t.Fatalf("fixture %+v collides with a production entry", change)
}
reviewedParameterTypeChanges[change] = struct{}{}
t.Cleanup(func() { delete(reviewedParameterTypeChanges, change) })
}
func reviewedFormatTypeFixture() parameterTypeChange {
return parameterTypeChange{
ToolPath: "doc/doc.create",
Parameter: "format",
From: `"string"`,
To: `"integer"`,
}
}
const docCreateFormatTypeFailure = `schema tool "doc/doc.create" parameter "format" changed type`
func assertSchemaFailureContains(t *testing.T, failures []string, want string) {
t.Helper()
for _, failure := range failures {
if strings.Contains(failure, want) {
return
}
}
t.Fatalf("failures %v do not contain %q", failures, want)
}
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChange(t *testing.T) {
baseline := cloneContract(baselineContract())
current := cloneContract(baseline)
mutateParameter(&current, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
// Without an entry the migration is still a blocking change.
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
t.Fatalf("a reviewed parameter type migration should pass: %v", failures)
}
}
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeIsDirectionSensitive(t *testing.T) {
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
// The reverse migration shares tool and parameter but not direction, so the
// string->integer entry must not admit it.
baseline := cloneContract(baselineContract())
mutateParameter(&baseline, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
current := cloneContract(baselineContract())
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
}
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsOtherToolsAndParameters(t *testing.T) {
baseline := cloneContract(baselineContract())
current := cloneContract(baseline)
mutateParameter(&current, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
// Same parameter and direction, different tool.
other := reviewedFormatTypeFixture()
other.ToolPath = "doc/doc.elsewhere"
registerReviewedParameterTypeFixture(t, other)
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
// Same tool and direction, different parameter.
elsewhere := reviewedFormatTypeFixture()
elsewhere.Parameter = "title"
registerReviewedParameterTypeFixture(t, elsewhere)
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
}
// A reviewed migration is only accepted when the rest of the parameter's
// contract held still. Each case bundles one unrelated regression with the
// reviewed type change and requires the type failure to reappear, so the
// exemption cannot carry a second change in behind it.
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsBundledRegression(t *testing.T) {
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
tests := []struct {
name string
mutate func(*parameterSchema)
}{
{name: "changed default", mutate: func(p *parameterSchema) { p.Default = `"text"` }},
{name: "changed interface_default", mutate: func(p *parameterSchema) { p.InterfaceDefault = `"text"` }},
{name: "changed format", mutate: func(p *parameterSchema) { p.Format = "uri" }},
{name: "redirected property", mutate: func(p *parameterSchema) { p.Property = "renamed" }},
{name: "redirected interface_type", mutate: func(p *parameterSchema) { p.InterfaceType = "integer" }},
{name: "newly required", mutate: func(p *parameterSchema) { p.Required = true }},
{name: "newly cli_required", mutate: func(p *parameterSchema) { p.CLIRequired = true }},
{name: "changed required_when", mutate: func(p *parameterSchema) { p.RequiredWhen = "always" }},
{name: "narrowed enum", mutate: func(p *parameterSchema) { p.Enum = []string{"markdown"} }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
baseline := cloneContract(baselineContract())
current := cloneContract(baseline)
mutateParameter(&current, func(parameter *parameterSchema) {
parameter.Type = `"integer"`
test.mutate(parameter)
})
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
})
}
}
// Every drift below is individually *compatible* — on its own it produces no
// failure at all — which is precisely why the carve-out cannot be keyed on "the
// gate recorded no other failure for this parameter". Bundled with a reviewed
// type migration they must still be rejected: none of them were reviewed under
// that entry, and admitting them would make the exemption wider than what the
// table documents.
//
// Each case first asserts that the drift alone really is compatible, so a future
// rule change that starts reporting it turns into an explicit failure here
// rather than quietly turning this test into a duplicate of the bundled-
// regression cases above.
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsIndividuallyCompatibleDrift(t *testing.T) {
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
tests := []struct {
name string
baseline func(*parameterSchema)
current func(*parameterSchema)
}{
{
name: "relaxed required",
baseline: func(p *parameterSchema) { p.Required = true },
current: func(p *parameterSchema) { p.Required = false },
},
{
name: "relaxed cli_required",
baseline: func(p *parameterSchema) { p.CLIRequired = true },
current: func(p *parameterSchema) { p.CLIRequired = false },
},
{
name: "cleared required_when",
baseline: func(p *parameterSchema) { p.RequiredWhen = "always" },
current: func(p *parameterSchema) { p.RequiredWhen = "" },
},
{
name: "widened enum",
current: func(p *parameterSchema) { p.Enum = []string{"html", "markdown", "text"} },
},
{
name: "cleared interface_type",
baseline: func(p *parameterSchema) { p.InterfaceType = "string" },
current: func(p *parameterSchema) { p.InterfaceType = "" },
},
{
name: "cleared property through reviewed mapping exclusion",
current: func(p *parameterSchema) {
p.Property = ""
p.PropertySource = propertySourceReviewedMappingExclusion
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
baseline := cloneContract(baselineContract())
if test.baseline != nil {
mutateParameter(&baseline, test.baseline)
}
driftOnly := cloneContract(baseline)
mutateParameter(&driftOnly, test.current)
if failures := checkCompatibility(baseline, driftOnly); len(failures) != 0 {
t.Fatalf("前提不成立:该变化本身已不兼容,用例测不到相等性守卫: %v", failures)
}
bundled := cloneContract(baseline)
mutateParameter(&bundled, func(parameter *parameterSchema) {
parameter.Type = `"integer"`
test.current(parameter)
})
assertSchemaFailureContains(t, checkCompatibility(baseline, bundled), docCreateFormatTypeFailure)
})
}
}
// The behaviour tests above register their own fixtures, so they cannot catch a
// production entry whose type values are spelled in the wrong form. That mistake
// disables the exemption silently and only a real gate run reports it — exactly
// how reviewedInterfaceRefRedirect was broken twice. Recompute both values
// through schemaType instead of trusting the spelling in the table.
func TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical(t *testing.T) {
if len(reviewedParameterTypeChanges) == 0 {
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
}
for change := range reviewedParameterTypeChanges {
if strings.Count(change.ToolPath, "/") != 1 ||
strings.HasPrefix(change.ToolPath, "/") || strings.HasSuffix(change.ToolPath, "/") {
t.Errorf("tool path %q 不是 \"<product id>/<tool id>\" 形态", change.ToolPath)
}
if strings.TrimSpace(change.Parameter) != change.Parameter || change.Parameter == "" ||
strings.HasPrefix(change.Parameter, "-") {
t.Errorf("%s: 参数名应是 Schema 里的裸名,登记为 %q", change.ToolPath, change.Parameter)
}
if change.From == change.To {
t.Errorf("%s %s: From 与 To 相同,不构成迁移", change.ToolPath, change.Parameter)
}
for _, side := range []struct {
label string
value string
}{{"From", change.From}, {"To", change.To}} {
var name string
if err := json.Unmarshal([]byte(side.value), &name); err != nil {
t.Errorf("%s %s 的 %s=%q 不是合法的 JSON type 值(schemaType 产出带引号的形态,如 `\"string\"`): %v",
change.ToolPath, change.Parameter, side.label, side.value, err)
continue
}
if got := schemaType(map[string]any{"type": name}); got != side.value {
t.Errorf("%s %s 的 %s 不是规范形态\n 登记: %s\n 规范: %s",
change.ToolPath, change.Parameter, side.label, side.value, got)
}
// 复算只能证明引号形态对,证明不了类型名本身没拼错——"integar" 同样
// 能通过复算。JSON Schema 的 type 取值是封闭集合,直接校验。
switch name {
case "string", "integer", "number", "boolean", "array", "object", "null":
default:
t.Errorf("%s %s 的 %s=%q 不是 JSON Schema 的合法 type 名",
change.ToolPath, change.Parameter, side.label, name)
}
}
}
}
func baselineContract() schemaContract {
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
"doc": {Tools: map[string]toolSchema{
@@ -693,6 +926,37 @@ func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing
}
}
func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testing.T) {
const toolPath = "doc/doc.shortcut_import"
oldTool := toolSchema{Constraints: `{"require_one_of":[["folder","workspace"]]}`}
newTool := oldTool
newTool.Constraints = ""
if !compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
t.Fatal("reviewed doc import target removal must be accepted")
}
if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 {
t.Fatalf("reviewed doc import constraint transition failed: %v", failures)
}
for _, test := range []struct {
name string
path string
old string
new string
}{
{name: "unlisted tool", path: "doc/doc.other", old: oldTool.Constraints, new: ""},
{name: "unlisted source", path: toolPath, old: `{"require_one_of":[["folder","workspace","name"]]}`, new: ""},
{name: "unlisted target", path: toolPath, old: oldTool.Constraints, new: `{}`},
} {
t.Run(test.name, func(t *testing.T) {
if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) {
t.Fatal("unreviewed constraint transition unexpectedly passed")
}
})
}
}
// Clearing a property through the reviewed mapping exclusion table is the one
// accepted shape, mirroring the interface_type retirement allowance. A leaf
// whose backing RPC moved to a nested payload has no honest flat property to
+1 -1
View File
@@ -50,7 +50,7 @@ cli_version: ">=1.0.15"
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-misc` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 41 | `dingtalk-doc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 6 | `dingtalk-minutes` |
+19 -33
View File
@@ -1,27 +1,20 @@
# 文档 (doc) 命令参考
## 查询命令帮助
## 路由与帮助预算
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
已知精确命令和参数时直接执行,禁止预调用 Help。参数语义不确定时先查精确 leaf Schema;真实返回 `unknown flag` 后最多读取一次该 leaf Help。`unknown command` 只执行一次 `dws shortcut list --service doc --format json`,禁止试探候选后缀或执行产品级 `dws doc --help | grep/head`。
本页已给出精确命令且参数足够时,禁止读取 reference 子页。只有复杂 JSONML、block/划词评论高级参数、分页或部分成功恢复仍无法由本页和精确 leaf Schema处理时,才最多读取一个对应子 reference;普通创建、全文读取、append/overwrite、导出和导入直接执行。
版本快照、列表和恢复的 Agent 主入口固定为:
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc permission --help
dws doc +version-save --node <DOC_ID>
dws doc +version-list --node <DOC_ID> --limit 20
dws doc +version-revert --node <DOC_ID> --version <N>
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。
## 命令总览
@@ -1005,26 +998,25 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706
| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) |
| `--content-file path` | 从文件读取(UTF-8),推荐 |
### 短/中等长度(< 200KB)— 单步创建
### 创建并写入 — Runtime 自动分片
```bash
# 1. 把内容写入 UTF-8 文本文件:
# Linux/Mac: /tmp/<name>.md;Windows: %TEMP%\<name>.md
# 2. 一步创建+写入:
dws doc create --name "<文档名>" --content-file <tmp> [--folder <ID>] [--workspace <ID>]
# 2. 一步创建、必要时自动分片并回读验证:
dws doc +create --name "<文档名>" --content @<工作目录相对文件> [--folder <ID>] [--workspace <ID>]
```
### 超长(> 200KB 兜底)— 创建空文档 + 分片追加
`+create` 会按安全边界拆分长 Markdown,首片创建、后续片追加,每个写调用最多执行一次。若返回 `partial_success` 或 `unknown`,先按返回的 `nodeId/steps` 回读文档,不得重跑整个创建。
### 更新并验证
```bash
# 1. 创建空文档拿 nodeId
dws doc create --name "<文档名>" [--folder/--workspace] # → nodeId
# 2. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格)
# 3. 逐个追加:
dws doc update --node <nodeId> --content-file <part> --mode append
dws doc +update --node <nodeId> --command append --content @<工作目录相对文件>
```
Runtime 自动分片并在最后回读验证。重要覆盖使用 `+checkpoint-update`;不要自行编写重试循环,创建和追加在超时后可能已经提交。
### stdin 变体
```bash
@@ -1085,12 +1077,6 @@ dws doc media upload --node <DOC_ID> --file ./icon.svg \
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
## 相关产品
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""
在指定目录创建文档并写入 Markdown 内容(一键完成)
用法:
python doc_create_and_write.py \
--name "项目周报" \
--content "# 本周总结\n\n## 完成事项\n- 任务A"
python doc_create_and_write.py \
--name "会议纪要" \
--content-file notes.md
python doc_create_and_write.py \
--name "知识库文档" --content "# 内容" --folder FOLDER_ID
python doc_create_and_write.py --name "test" --content "hello" --dry-run
"""
import sys
import json
import time
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run_dws_with_retry(
args: List[str],
dry_run: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
) -> Optional[Any]:
"""带重试机制的 dws 命令执行"""
last_error = None
for attempt in range(1, max_retries + 1):
result = run_dws(args, dry_run=dry_run)
if result is not None:
return result
if attempt < max_retries:
print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
time.sleep(retry_delay)
retry_delay *= 1.5 # 指数退避
return None
def main():
parser = argparse.ArgumentParser(
description='创建文档并写入内容'
)
parser.add_argument('--name', required=True, help='文档名称')
parser.add_argument('--content', default='', help='Markdown 内容')
parser.add_argument('--content-file', default='', help='内容文件')
parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL')
parser.add_argument('--workspace', default='', help='目标知识库 ID')
parser.add_argument(
'--mode', default='append', choices=['overwrite', 'append'],
help='写入模式: overwrite=覆盖, append=追加 (默认 append)',
)
parser.add_argument(
'--max-retries', type=int, default=3,
help='每块写入失败时的最大重试次数 (默认 3)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
content = args.content
if args.content_file:
p = Path(args.content_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
content = p.read_text(encoding='utf-8')
if not content:
print('错误:需要 --content 或 --content-file')
sys.exit(1)
chunk_size = 30000
create_args = ['doc', 'create', '--name', args.name, '--format', 'json']
if args.folder:
create_args.extend(['--folder', args.folder])
if args.workspace:
create_args.extend(['--workspace', args.workspace])
print(f'\n📝 创建文档: {args.name}')
create_data = run_dws(create_args, dry_run=args.dry_run)
node_id = None
if not args.dry_run:
if not create_data:
sys.exit(1)
node_id = (create_data.get('nodeId')
or create_data.get('dentryUuid')
or create_data.get('id', ''))
print(f" ✓ 文档已创建 (ID: {node_id})")
if len(content) <= chunk_size:
mode_label = '追加' if args.mode == 'append' else '覆盖'
print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...')
write_data = run_dws([
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', content,
'--mode', args.mode,
'--format', 'json',
], dry_run=args.dry_run)
if write_data:
print(f" ✓ 内容已写入 ({len(content)} 字符)")
else:
chunks = []
pos = 0
while pos < len(content):
end = min(pos + chunk_size, len(content))
if end < len(content):
newline_pos = content.rfind('\n', pos, end)
if newline_pos > pos:
end = newline_pos + 1
chunks.append(content[pos:end])
pos = end
total_chunks = len(chunks)
print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...')
success_chunks = 0
for idx, chunk in enumerate(chunks):
chunk_mode = args.mode if idx == 0 else 'append'
write_data = run_dws_with_retry(
[
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', chunk,
'--mode', chunk_mode,
'--format', 'json',
],
dry_run=args.dry_run,
max_retries=args.max_retries,
)
if write_data:
print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)")
success_chunks += 1
elif not args.dry_run:
# 写入失败,报告部分写入状态
print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)")
print(f"\n⚠️ 文档处于部分写入状态:")
print(f" - 文档 ID: {node_id}")
print(f" - 已写入: {success_chunks}/{total_chunks} 块")
print(f" - 失败位置: 第 {idx + 1} 块")
if args.mode == 'overwrite':
print(f" - 模式: 覆盖模式,文档可能包含不完整内容")
print(f" - 建议: 手动检查文档内容,或删除后重新创建")
else:
print(f" - 模式: 追加模式,已写入内容已保存")
print(f" - 建议: 可手动补充剩余内容,或重新运行脚本")
sys.exit(1)
print('\n✅ 完成!')
if __name__ == '__main__':
main()
+72 -156
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
description: 钉钉在线文字文档(adoc)的查找、读取、创建、编辑、块、评论、附件、导入导出、模板、版本和权限协作。普通文件走 dingtalk-drive,知识库空间走 dingtalk-wiki。命令前缀:dws doc。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -11,179 +11,95 @@ metadata:
# 钉钉文档 Skill
## 前置条件 — 执行操作前必读
<!-- DWS_RUNTIME_CONTRACT_START -->
## 最小 DWS 执行契约
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[doc.md](references/doc.md);剧本:[04-document.md](references/04-document.md)。
## 参数硬约束
- 创建文档只用 `--name`,不要写 `--title`。
- 目标文件夹只用 `--folder <文档文件夹nodeId或URL>`,不要写 `--parent` / `--parent-node` / `--parent-id`。
- 目标知识库只用 `--workspace <workspaceId或URL>`,不要写 `--space-id` / `--spaceId`。
- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。
- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。
- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。
- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。
- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。
- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。
- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。
- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。
- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。
- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。
- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。
- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。
- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dingtalk-shared` 中对应 reference;不要连续猜测替代命令。
<!-- DWS_RUNTIME_CONTRACT_END -->
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
## Shortcut 发现(按需)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "doc +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws doc <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service doc --format json` 批量发现。
`doc` 当前有 45 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图按下方路由。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws doc +access-change` | write | 预检已有协作者后变更文档角色 |
| `dws doc +access-grant` | write | 按姓名解析后批量授予文档权限 |
| `dws doc +access-revoke` | high-risk-write | 预检并移除指定协作者的文档权限 |
| `dws doc +background-delete` | write | 清除文档背景色 |
| `dws doc +background-update` | write | 设置文档 #RRGGBB 背景纯色 |
| `dws doc +checkpoint-update` | write | 先保存可回滚版本,再更新并读回验证 |
| `dws doc +comment-create` | write | 在文档上创建一条评论 |
| `dws doc +comment-delete` | high-risk-write | 永久删除指定文档评论 |
| `dws doc +comment-list` | read | 查询文档评论列表 |
| `dws doc +comment-reply` | write | 回复文档中的一条评论 |
| `dws doc +comment-update` | write | 更新指定文档评论正文和 mention |
| `dws doc +copy` | write | 复制文档/文件到指定文件夹或知识库 |
| `dws doc +create` | write | 从 Markdown 或 JSONML 创建在线文字文档 |
| `dws doc +create-from-template` | write | 按 templateId 直达或搜索消歧后创建文档 |
| `dws doc +doc-append` | write | 在文档末尾追加一段文本(安全追加,不改动原有内容) |
| `dws doc +export` | read | 提交、轮询并安全下载在线文档导出文件 |
| `dws doc +export-get` | read | 根据 jobId 查询文档导出任务结果 |
| `dws doc +export-submit` | read | 提交在线文档导出任务 (docx/markdown/pdf),返回 jobId |
| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 |
| `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) |
| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 |
| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库 |
| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 |
| `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 |
| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 |
| `dws doc +media-insert` | write | 上传本地图片或文件并插入文档正文 |
| `dws doc +media-list` | read | 列出文档正文中的图片和附件资源 |
| `dws doc +media-preview` | read | 下载正文媒体到受控临时目录并返回预览路径 |
| `dws doc +move` | write | 移动文档/文件到指定文件夹或知识库 |
| `dws doc +resource-delete` | high-risk-write | 幂等清除文档封面 |
| `dws doc +resource-download` | read | 读取并安全下载当前文档封面 |
| `dws doc +resource-update` | write | 从本地图片或 HTTPS URL 设置文档封面 |
| `dws doc +review` | read | 聚合未解决评论、引用原文和块上下文 |
| `dws doc +search` | read | 按关键词搜索有权限的文档 (不传则返回最近访问) |
| `dws doc +share-doc` | write | 按姓名把文档链接私信发给某人(自动解析 userId) |
| `dws doc +template-list` | read | 获取文档模板列表 |
| `dws doc +template-search` | read | 根据关键词搜索文档模板 |
| `dws doc +update` | write | 追加、覆盖或按 block 精确更新文档内容 |
| `dws doc +version-list` | read | 查看文档历史版本列表 |
| `dws doc +version-revert` | high-risk-write | 回滚文档到指定历史版本 |
| `dws doc +version-save` | write | 手动保存文档版本快照 |
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service doc --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
## Golden Route
| 用户说 | 命令 |
|--------|------|
| "创建文档(短内容)" | `dws doc create --name "<标题>" --content "<内容>"` |
| "创建+写入(长内容自动分块)" | `python scripts/doc_create_and_write.py --name "<标题>" --content "<内容>" [--mode append\|overwrite]` |
| "搜在线文字文档 / 找在线文字文档" | `dws drive search --query "<关键词>" --format json` → `dws drive info --node <nodeId> --format json` → 仅 `extension=adoc` 使用 `dws doc read --node <nodeId> --format json` |
| "读在线文字文档(adoc)内容" | `dws doc read --node <nodeId> --format json` |
| "更新文档内容 / 分块追加" | `dws doc update --node <nodeId> --content "<分块>" --mode append` |
| "删除块" | `dws doc block delete`(需用户确认) |
| "导出 docx / markdown / pdf" | `dws doc export --node <nodeId> --export-format <docx|markdown|pdf> --output <path>` |
| "导入本地文件为在线文档" | `dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) |
| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) |
| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` |
| "在文档里创建空白板" | `dws doc whiteboard insert --node <nodeId> --yes --format json` |
| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node <nodeId> --file <path> --yes --format json` |
选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
## 标准 SOP(必遵流程)
| 用户意图 | 唯一推荐入口 | 关键边界 |
|---|---|---|
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
| 授权后向多人分享链接 | `dws doc +grant-and-share` | 返回逐人执行账本;部分失败不等于整体成功 |
| 插入或下载正文媒体 | `dws doc +media-insert/+media-download` | 本地路径必须位于工作目录;下载默认 no-clobber |
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 nodeId/blockId。结构化命令必须带 `--format json`,执行后必须按"验证"步回读真实字段。文件类操作(上传/下载/复制/移动)切 `dingtalk-drive`;知识库节点管理切 `dingtalk-wiki`。
## 关键结果语义
### SOP-1 查找并读取文档(query-doc)
- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
- 仅在结果明确且关键内容回读匹配后报告写入完成。
- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
**触发**:查文档/读文档/某文档在哪/搜文档内容。
## 参数与安全边界
1. **定位(必须)**:用户已提供 URL / `nodeId` 时直接使用原值;未提供目标时才执行 `dws drive search --query "<关键词>" --format json`,再取候选结果的真实 `nodeId`。
2. **探测(必须)**:对选中的候选执行 `dws drive info --node <nodeId> --format json`,从真实返回读取 `extension`;不得因为搜索结果标题像“文档”就跳过探测。
3. **按类型读取(必须)**:
- `extension=adoc`:`dws doc read --node <nodeId> --format json`;大文档只抽取用户需要的章节。
- `extension=md`:切到 `dingtalk-misc` 的 `references/markdown.md`,用 `dws markdown fetch --node <nodeId> --format json` 读取原文;仅需文件实体下载时切 `dingtalk-drive` 用 `drive download`。
- `extension=axls`:切到 `dingtalk-misc`,读取 `references/sheet.md` 后按电子表格意图执行。
- `extension=able`:切到 `dingtalk-aitable`。
- `extension=xlsx` / `xls` / `xlsm` / `csv` 或其他普通文件:切到 `dingtalk-drive`;不得执行 `dws doc read`。
- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +<leaf>" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
**禁止**:用户未提供目标时跳过搜索并猜 nodeId、未探测类型就执行 `doc read`、把整篇文档原样贴给用户。
## 按需加载
### SOP-2 创建文档并写入(create-doc)
Golden Route 已给出命令且参数足够时,禁止读取 reference。其余仅在下列语义不明确时,才最多读取一个 reference:
**触发**:新建文档/写一篇/建文字文档。
| 触发条件 | Reference |
|---|---|
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
1. **执行(必须)**:`dws doc create --name "<标题>" --content-file <tmp.md> [--folder <FOLDER_NODE_ID> | --workspace <WORKSPACE_ID>] --format json`(长/多行内容用 `--content-file`,不要用 `--content` 拼长串;用户未指定位置时省略两个位置参数,创建到“我的文档”根目录)。
2. **验证(必须)**:从返回取 `nodeId`,立即 `dws doc info --node <nodeId> --format json` 回读确认。
`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
**禁止**:创建后不回读就答复"已创建"、把 `--folder` 当成空间 ID 传入。
## 错误最短路径
### SOP-3 覆盖/追加内容(write-content)
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
**触发**:覆盖写/追加内容/改文档正文。
## 跨产品边界
1. **执行(必须)**:覆盖先执行 `dws doc update --node <nodeId> --mode overwrite --content-file <tmp.md> --dry-run --format json` 预览,用户确认后改用 `--yes` 实际覆盖;追加执行 `dws doc update --node <nodeId> --mode append --content-file <tmp.md> --format json`。
2. **验证(必须)**:写后 `dws doc read --node <nodeId> --format json` 抽取受影响段落核对。
**禁止**:不加 `--yes` 反复重试覆盖、跳过 `--dry-run` 直接覆盖未确认的长文档。
### SOP-4 导出 / 下载(export-doc)
**触发**:导出文档/下载文档/转 PDF·Markdown。
1. **判类型(必须)**:先 `dws drive info --node <nodeId> --format json`;`extension=adoc` → `dws doc export --node <nodeId> --export-format <pdf|markdown|docx> --output <path> --format json`;普通文件 → 切 `dingtalk-drive` 用 `dws drive download --node <nodeId> --output <path> --format json`。
**禁止**:不分类型一律走 `doc export`(普通文件会失败)、跳过 `drive info` 判断。
### SOP-5 块级编辑(block-edit)
**触发**:插引用块/代码块/表格/分栏/图片/附件,或删除某块。
1. **先列块(必须)**:`dws doc block list --node <nodeId> --format json`,当前响应的可操作块 ID 位于 `blocks[].element.id`(部分版本可能回显为 `blockId`);必须从目标内容对应项读取,不得编造。空文档的占位空段落可能不能作为 `--ref-block`。
2. **按动作执行(必须)**:
- 插入:默认追加用 `dws doc block insert --node <nodeId> --text "<内容>" --format json`;只有明确要求相对位置时才加 `--ref-block <非空参照块ID> --where before|after`,容器内插入使用 `--parent-block <父块ID> --index <位置>`。插入命令**不接受** `--block-id`。
- 更新:`dws doc block update --node <nodeId> --block-id <目标blockId> --text "<新内容>" --format json`。
- 删除:用户确认后执行 `dws doc block delete --node <nodeId> --block-id <目标blockId> --yes --format json`。
3. **验证(必须)**:再次执行 `dws doc block list --node <nodeId> --format json` 核对插入、更新或删除结果。
4. **复杂块(必须)**:插入引用/代码/表格/分栏/附件/图片前,**必须**先读 [doc.md](references/doc.md) 对应小节,**禁止**只停在"准备查看 help"——说"我将插入..."后必须立即执行命令。
**禁止**:编造 blockId、未确认就删除、把完整 `--help` 输出当成最终结果答复用户。
### SOP-6 导入本地文件为在线文档(import-file)
**触发**:导入 Word / Excel / Markdown / 本地文件为在线文档。
1. **判类型(必须)**:确认用户意图是“导入为在线文档”,不是“上传到钉盘”。仅上传存储时切 `dingtalk-drive`。
2. **执行(必须)**:`dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`;复杂参数和限制见 [doc-import.md](references/doc/doc-import.md)。
3. **验证(必须)**:拿到返回 `nodeId` 后执行 `dws doc info --node <nodeId> --format json`,必要时 `dws doc read --node <nodeId> --format json` 抽样核对内容。
**禁止**:把上传文件到钉盘误当成 doc import;不知道目标文件夹 nodeId 时先切 `dingtalk-drive`/`dingtalk-wiki` 查询。
## 多步文档短路径
- 在目标文件夹创建文字文档:`dws doc create --name "<标题>" --folder <FOLDER_NODE_ID> --content-file <tmp.md> --format json`。拿到 `nodeId` 后立即回读。
- 块级编辑固定顺序:`doc block list --node <nodeId>` → 插入用 `--ref-block`/`--parent-block`,更新或删除用 `--block-id` → `doc block list` 验证。删除块必须已有用户明确删除意图或二次确认。
- 插入引用块、代码块、表格、分栏、附件、图片时,优先读 [doc.md](references/doc.md) 对应小节,不要只停在"准备查看 help"。说出"我将插入..."后必须立即执行对应 terminal 调用。
- 用户要求多个子文档/附件/块操作时,按 checklist 串行完成;最后一条 assistant 消息不能停在"接下来我要...",必须有实际工具调用或明确失败原因。
- 用户说“读取并下载/导出”时,先 `drive info --node ... --format json` 按
`extension` 判断类型:`adoc` 用 `doc export`,普通文件切到
`dingtalk-drive` 用 `drive download`。
- 所有结构化 dws 命令带 `--format json`。仅参数不确定时查 `--help`,不要把完整 help 当成最终结果。
## 危险操作
`block delete` 不可逆,必须确认再加 `--yes`。
## 跨产品协作
- 文件存储 / 上传下载 → 切到 `dingtalk-drive`
- 知识库空间管理 → 切到 `dingtalk-wiki`
- 数据表 → 切到 `dingtalk-aitable`
- 原生 `.md` 文件读取、创建、全量覆盖或局部替换 → 切到 `dingtalk-misc` 的 `references/markdown.md`
- 长篇报告生成(多源采集 + 写文档)→ 此 skill 提供 `doc_create_and_write.py` 脚本
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
- `axls` / `able` → 对应电子表格或多维表 Skill
- 持续监听文档事件 → `dingtalk-misc`
@@ -1,70 +1,57 @@
# 文档知识
# 文档组合任务
> lite recipe 见 [SKILL.md 速查表](../SKILL.md)。
本页只描述跨步骤组合任务。单一创建、读取、更新、导出或媒体操作直接使用根 Skill Golden Route,不要加载本页。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| import-file | 1. **直接执行** `dws doc import --file <本地文件路径> --format json`(一条命令完成上传+转换+创建)<br>2. 从返回中提取 `documentUrl` 并告知用户<br>3. **禁止先 Read 文件内容再 `doc create` + `doc update`**——`doc import` 是服务端格式转换,客户端无需解析文件内容<br>4. 可选参数:`--folder <文件夹ID>` 指定目标文件夹、`--workspace <知识库ID>` 指定目标知识库、`--name "文档名"` 自定义名称<br>5. 格式映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档<br>6. 超时或中断时 CLI 返回 `taskId`,用 `dws doc import get --task-id <taskId>` 手动查询<br>详见 [doc-import.md](./doc/doc-import.md) |
| write-doc | 0. 阅读 [doc-create-workflow.md](./doc/style/doc-create-workflow.md) 的 §前置必读 + §关键词速查表,锁定文档类型和起稿路径:**决策型/含对比的知识沉淀型/用户要求美观 → JSONML 起稿**(`.json`);执行型/说明型 → Markdown 起稿(`.md`)<br>1. 按选定路径执行 doc-create-workflow.md(JSONML 路径有骨架范例可直接复制修改)<br>2. `doc create --content-file /tmp/<name>.json --content-format jsonml`(或 `.md` + `--content-format markdown`)<br>3. 大内容默认依赖 DWS 自动分片;只有 `CONTENT_TRUNCATED`、部分写入失败或回读发现缺失时,才按 workflow 的恢复流程手工补片<br>4. **回读校验(必须)**:所有写入完成后,执行 `doc read --node <nodeId>`,校验关键标题/段落/表格是否完整写入 |
| search-docs-and-share | 1. `dws drive search --query "<关键词>" --format json` → 取候选 `nodeId` + 标题建索引(不读全文)<br>2. 对追问选中的候选执行 `dws drive info --node <nodeId> --format json`<br>3. 仅 `extension=adoc` 使用 `dws doc read --node <nodeId> --format json`(最多 2 篇);`md` / `axls` / `able` / 普通文件分别切到 markdown / sheet / aitable / drive,禁止固定执行 `doc read` |
| create-knowledge-base | 1. 创建知识库空间取 `WS_ID`<br>2. `wiki node create --workspace <WS_ID> --name "<文档名>"` → 取 `nodeId`<br>3. `wiki node list --workspace <WS_ID>` 确认 |
| migrate-doc | 1. `doc read --node <源nodeId>` → 取正文并写入临时文件 `<tmp>.md`<br>2. `doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID> --content-file <tmp>.md` → 取新 `nodeId`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId;正文 <200KB 单步到位)<br>2a. 若正文 >200KB:**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID>` → `nodeId` → 按段落切片 → 每片 `doc update --node <nodeId> --content-file <part> --mode append`<br>3. **回读校验**:`doc read --node <nodeId>` 校验内容完整性 |
| update-doc-section | 1. `dws drive search --query "<关键词>" --format json` → 取 `nodeId`<br>2. `dws drive info --node <nodeId> --format json`,仅 `extension=adoc` 继续;其他类型切到对应 skill/reference<br>3. **形态选择(按 [doc-update-workflow.md §1.3](./doc/style/doc-update-workflow.md) 优先级)**:目标段落含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构 → 走 `jsonml-node-edit`;纯文本替换且确认无富结构 → 继续本 recipe<br>4. `dws doc read --node <nodeId> --format json` 定位目标章节<br>5. `dws doc update --node <nodeId> --content "<替换内容>" --mode overwrite --yes --format json`<br>6. **回读校验**:`dws doc read --node <nodeId> --format json` 确认 overwrite 未被降级为 append、内容完整无截断<br>**overwrite 须用户确认**;完整改写流程见 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) |
| rewrite-doc | 1. 阅读并执行 [doc-update-workflow.md](./doc/style/doc-update-workflow.md):先看 §1.3 编辑形态优先级(**JSONML 首选**),再按 §3 速查表选路径,跳 §4 对应小节执行<br>2. 单块改写 / 含富结构 → §4.4 路径 B;多处保真改写或改 root → §4.4 路径 A;纯文本骨架重写 → §4.5 markdown<br>3. 整篇 overwrite 前必须按 workflow §4.5 向用户提示风险并等待确认<br>4. **回读校验(必须)**:按 workflow §6 的校验要点逐项核查;@人、附件、图片等保真要素必须原样保留<br>**适用场景**:用户提供已有 nodeId/链接,需要改写、润色、章节补充、段落形态转换、整篇重写 |
| doc-to-message | 1. `doc read --node <nodeId>` → 取正文(大文档只摘要+链接)<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| lossless-doc-edit | 1. `doc read --node <nodeId> --content-format jsonml --output /tmp/doc.json` → 获取完整 JSONML 结构(输出含 `revision`,并发敏感时记下来;默认改写不需要)<br>2. 解析 JSON 文件,修改 `jsonml` 数组中的目标节点(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md))<br>3. 将修改后的内容写回临时文件 `/tmp/doc_modified.json`,格式为 `{"jsonml": [...]}`<br>4. `doc update --node <nodeId> --content-file /tmp/doc_modified.json --content-format jsonml --mode overwrite`(默认不做并发检查;担心多 agent 同时改时加 `--revision <第 1 步拿到的 N>` 触发并发检查,版本不一致返回 `VersionConflict` 时回到第 1 步重读重写)<br>5. **回读校验**:`doc read --node <nodeId> --content-format jsonml` 确认写入成功<br>**适用场景**:保留样式、精准插入特定节点类型、改属性不动文本;普通文本编辑仍优先用 markdown 模式。完整 JSONML 改写流程见 [doc-update-workflow.md §4.4](./doc/style/doc-update-workflow.md) |
| jsonml-node-edit | 1. `doc block list --node <nodeId> --content-format jsonml` → 获取 JSONML 节点列表(含 uuid)<br>2. 根据 uuid 定位目标节点<br>3. `doc block list --node <nodeId> --content-format jsonml --block-id <uuid>` → 读取完整子树<br>4. 修改 JSONML 节点内容(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md),可复制范例见 [doc-jsonml-cookbook.md](./doc/format/doc-jsonml-cookbook.md))<br>5. `doc block update --node <nodeId> --block-id <uuid> --content-format jsonml --element '<修改后的 JSONML>'` → 写回<br>**适用场景**:只改一个 block 的结构/样式,无需全文回写;写入端默认 normalize(自动补 uuid、裸字符串包成 canonical 文本),可用 `--no-fix-jsonml` 关闭全部修复;`--fix-jsonml` 额外启用 JSON 语法修复(推荐 agent 调用) |
## 创建成稿
## template-based-generation
1. 把最终正文写到工作目录内的 `body.md` 或 `body.json`。
2. 执行一次 `dws doc +create --name "<标题>" --content @body.md --format json`。
3. 使用返回的 `verified/nodeId` 判断完成;只有用户明确要求额外结构验收时再做局部 `+fetch`。
当用户提供已有 alidocs 文档并要求“按模板生成、复刻、生成同形态的新版本”时,必须走保形复制链路:
复杂排版才按需读取 style/JSONML Reference;执行入口仍使用 `+create`。禁止调用已删除的创建脚本。
1. `dws drive info --node <源文档ID或URL> --format json`,确认源节点存在并记录类型。
2. `dws drive copy --node <源文档ID或URL> [--folder <目标文件夹>] [--workspace <目标知识库>] --format json`,从返回中提取副本 `nodeId`。
3. 如需改名,只对副本执行 `dws drive rename --node <副本nodeId> --name "<新名称>" --format json`。
4. `dws doc block list --node <副本nodeId> --content-format jsonml` 定位需要替换的块;仅对副本执行 `dws doc block update`。涉及多个块或富结构时,按 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) 的 JSONML 路径操作。
5. 再次读取副本的 `drive info` 和目标块,确认名称、内容与结构;禁止修改源文档。
**禁止**用 `doc read` → `doc create` 重建模板。Markdown 是有损投影,会丢失行高、单元格背景色、字号和部分富结构。
---
## 分块 append 截断风险提示
### 触发条件
当内容总大小 **超过 200KB**,需要拆分为多片通过 `doc update --mode append` 分块写入时,**必须在执行前向用户发出截断风险提示**,等待用户确认后再继续。
### 提示话术(参考模板)
> 注意: 内容较长(约 {size}),需要分 {n} 片写入。分块 append 存在以下风险:
> - 部分片段可能写入失败但返回 success,导致文档**内容截断或缺失**
> - 片段之间的表格、代码块等跨块元素可能**被截断破坏**
> - 写入顺序异常可能导致**段落错乱**
>
> 建议:写入完成后我会回读校验文档完整性。是否继续?
### 执行规范
1. **提示时机**:在执行第一片 append **之前**提示,而非写入过程中
2. **分片原则**:按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断
3. **逐片校验**(推荐):每写入一片后记录已写入的最后一个标题/段落标记,供最终回读时比对
4. **最终回读**(必须):所有片段写入完成后,执行 `doc read --node <nodeId>` 回读全文,逐片比对关键标记是否完整(详见下方「doc update 回读校验规范」)
5. **失败处理**:若回读发现缺失片段,向用户报告具体缺失位置,建议针对缺失部分单独重试 append
---
## doc update 回读校验规范
**所有 `doc update`(含 overwrite 和 append)执行后都必须回读校验**——返回 `success=true` 不等于内容真的写入完整。
完整规范(静默失败场景、校验流程、异常处理路径、"先清空再重建"命令样板)见 [doc-update-workflow.md §6「回读验收」](./doc/style/doc-update-workflow.md)。
最小流程:
## 导入文件
```bash
dws doc update --node <nodeId> --content-file /tmp/new-content.md --mode overwrite
dws doc read --node <nodeId> # 校验关键标题、段落首句、表格、@人、附件
dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
```
**禁止**在未回读的情况下向用户报告「已完成」。
导入是服务端格式转换。禁止先读文件内容再走 `create + update`。单纯保存普通文件切换到 `dingtalk-drive` 上传。
## 查找并读取
```bash
dws doc +fetch --query "<唯一标题>" --format json
```
`+fetch --query` 会跨页解析唯一在线文字文档。零命中、多候选或类型不是 `adoc` 时停止并按返回候选消歧;不要继续调用 drive/wiki/aisearch 做无界穷举。
## 更新章节
1. `+fetch --node <ID> --scope section|keyword` 读取最小必要上下文。
2. 普通编辑用 `+update`;重要覆盖用 `+checkpoint-update`。
3. 使用 shortcut 的验证结果;`partial_success/unknown` 时只恢复缺失步骤。
## 从模板创建
1. `+template-search --query <名称>`。
2. 唯一命中才取得 `templateId`;多候选要求用户选择。
3. `+create-from-template --template-id <ID>` 只执行一次。
模板保形复制已有文档是另一种任务:使用 drive copy 复制源文档,再只修改副本。禁止 `doc read → doc create` 重建富格式模板。
## 导出并归档
1. 在线文字文档使用 `+export` 导出到工作目录。
2. 检查 `localPath/sizeBytes`。
3. 用户要求归档到钉盘时,再用 `dingtalk-drive` 上传该文件。
导出失败不得安装本地转换依赖或直接下载临时 URL。
## 文档转消息/待办
1. 使用局部 `+fetch` 提取必要内容。
2. 在目标产品中解析真实用户/群/任务标识。
3. 根据目标产品 Runtime gate 确认后写入。
跨产品步骤必须复用稳定 ID;失败后不能重放已经成功的文档写入步骤。
@@ -0,0 +1,38 @@
# Doc Runtime Contracts
本文只定义 Agent 需要稳定消费的文档运行时语义。字段以 leaf Schema 和实际 JSON 返回为准;不要从终端展示文本反推状态。
## 目标
文档目标至少保留 `nodeId`、资源类型、canonical URL(服务返回时)和容器信息。名称或标题不是稳定身份。按自然标题解析出现零命中、多候选或分页不完整时,写操作必须停止。
## 写操作状态
| status | 含义 | 后续动作 |
|---|---|---|
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
## 分页与完整性
列表和搜索结果需要区分:
- `complete=true`:已证明覆盖请求范围;
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
- `failures[]`:已返回部分数据但某一页或后处理失败。
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
## 错误
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
## 安全落盘
导出、下载和媒体预览只使用工作目录内相对路径。完成条件包括临时文件写入成功、内容校验、文件关闭和原子发布;失败时不得留下看似最终产物的半成品。默认 no-clobber,覆盖必须由用户显式选择。
+48 -483
View File
@@ -1,510 +1,75 @@
# 文档 (doc) 命令参考
# dingtalk-doc 低频能力索引
> **渐进式文档**:本文件为路由层(命令索引 + 场景索引 + 意图判断 + 工作流),各命令的详细参数、示例和踩坑说明在 [doc/](./doc/) 目录下按需加载。
本页只在根 Skill 的 Golden Route 和精确任务 Reference 都无法选路时加载。它不是创建、读取或更新任务的前置必读,也不要求预加载样式、JSONML 或完整产品帮助。
## 文档地址 (URI)
## 高频入口
| 资源 | URI 格式 |
|------|----------|
| 文档节点 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` |
| edit / preview 链接 | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` |
| 意图 | 推荐命令 | 精确 Reference |
|---|---|---|
| 搜索在线文字文档 | `dws doc +search --query <关键词>` | [doc-info.md](doc/doc-info.md) |
| 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | [doc-read.md](doc/doc-read.md) |
| 创建并写入 | `dws doc +create` | [doc-create.md](doc/doc-create.md) |
| 追加、覆盖、block 编辑 | `dws doc +update` | [doc-update.md](doc/doc-update.md) |
| 重要更新与恢复点 | `dws doc +checkpoint-update` | [doc-update.md](doc/doc-update.md) |
| 导出本地文件 | `dws doc +export` | [doc-export.md](doc/doc-export.md) |
| 导入为在线对象 | `dws doc +import` | [doc-import.md](doc/doc-import.md) |
| 评论聚合与操作 | `dws doc +review/+comment-*` | [doc-comment.md](doc/doc-comment.md) |
| 媒体插入、列表、下载 | `dws doc +media-*` | [doc-media.md](doc/doc-media.md) |
> **操作后请返回文档 URI**:每次执行 create / read / update 等操作后,从返回数据中提取 `docUrl` 直接返回;缺失时用 `doc info --node <ID>` 补查。
命令已选定但参数不确定时读取精确 leaf Schema;只有 Cobra flag 与 Schema 冲突时读取精确 leaf Help。不要加载 `dws doc --help` 或完整 Catalog 代替选路。
## 前置条件 — 执行操作前必读
## 模板
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下子文件:**
1. **解析 URL / 定位文档**(几乎所有命令都需要先拿 nodeId)
→ 必读 [`doc/doc-info.md`](doc/doc-info.md)(URL/dentryKey 提取规则、ID 边界、extension 路由、**获取 nodeId 三种方式 A/B/C**)
2. **创建或编辑文档内容**(`doc create` / `doc update` / `doc block insert|update`)
→ 必读 [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md)(**形态优先级硬规则:JSONML > element JSON > markdown**;markdown overwrite 会丢富结构)
- 从零创建时加读 [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md)
- **任何 `doc create` 都必须先读 [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) §2.0 类型决策表 + §1 硬规则**(决定骨架 + 全局约束,不读就不知道用哪种骨架)
- 涉及 callout / 分栏 / 富 block 精修时再加读 style-guideline §4-§7 + [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md)
**未读以上文件就改写已有文档会导致富结构丢失、参数错误或样式不达标。其他命令(阅读 / 评论 / 权限 / 附件 / 下载导出 / 文件操作)按需查下方 §命令索引表跳转对应子文件加载,不必提前加载。**
## 查询命令帮助
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
只有名称时先只读搜索:
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc media --help
dws doc +template-search --query "周报" --source PUBLIC --format json
```
规则:
- `selection.status=resolved`:取唯一候选的 `templateId`。
- `selection.status=not_found`:报告零命中后停止。
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
## 命令索引表
> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。
### 阅读 / 元信息
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) |
| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) |
### 创建 / 写入 / 块编辑
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc create` | 创建文字文档(adoc) | `--name` | [`doc/doc-create.md`](./doc/doc-create.md) |
| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) |
| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) |
### 附件 / 评论 / 导出
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) |
| `doc comment list/create/reply/update/delete/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc export` / `doc export get` | 在线文档导出 docx/markdown/pdf;`export get` 输出已对齐统一 TaskResult 格式 | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 导入
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc import` / `doc import get` | 本地文件导入为在线文档(docx→文档 / xlsx→表格 / xmind→脑图 / md→文档);`import get` 输出已对齐统一 TaskResult 格式 | `--file` | [`doc/doc-import.md`](./doc/doc-import.md) |
### 版本管理
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc version save` | 手动保存文档版本快照 | `--node` | 本节 |
| `doc version list` | 查看文档历史版本列表 | `--node` | 本节 |
| `doc version revert` | 回滚文档到指定版本 | `--node` `--version` `--yes` | 本节 |
### 模板管理
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc template list` | 获取文档模板列表 | 无 | 本节 |
| `doc template search` | 搜索文档模板 | `--query` | 本节 |
| `doc template apply` | 应用文档模板创建新文档 | `--template-id` | 本节 |
#### 手动保存文档版本
```
Usage:
dws doc version save [flags]
Example:
dws doc version save --node <nodeId>
Flags:
--node string 文档 ID 或 URL (必填)
```
> 仅支持 adoc 类型文档。保存后会创建一个 USER_SAVE 类型的版本记录。
#### 查看文档历史版本列表
```
Usage:
dws doc version list [flags]
Example:
dws doc version list --node <nodeId>
dws doc version list --node <nodeId> --limit 10
Flags:
--node string 文档 ID 或 URL (必填)
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 回滚文档到指定版本
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc version revert [flags]
Example:
dws doc version revert --node <nodeId> --version 3 --yes
Flags:
--node string 文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--yes 跳过确认提示 (非交互终端必须传)
```
#### 获取文档模板列表
```
Usage:
dws doc template list [flags]
Example:
dws doc template list
dws doc template list --source MY
dws doc template list --source PUBLIC
dws doc template list --limit 20
Flags:
--source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY
--limit int 返回数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 搜索文档模板
```
Usage:
dws doc template search [flags]
Example:
dws doc template search --query "周报"
dws doc template search --query "会议纪要" --limit 10
dws doc template search --query "项目" --source PUBLIC
Flags:
--query string 搜索关键词 (必填)
--source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY
--limit int 返回数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 应用文档模板
```
Usage:
dws doc template apply [flags]
Example:
dws doc template apply --template-id TPL_ID --name "我的周报"
dws doc template apply --template-id TPL_ID --name "项目方案" --folder FOLDER_ID
Flags:
--template-id string 模板 ID (必填,从 template list/search 获取)
--name string 新文档名称 (可选)
--folder string 目标文件夹 ID (可选)
--workspace string 知识库 ID (可选)
```
### 文件操作(已迁移,以下命令 deprecated)
> **迁移提示**:文件管理命令已按产品领域架构重新归属。**Agent 必须使用右侧推荐命令,禁止使用左侧旧命令**(旧命令仅供人类用户过渡参考,可能已不可用)。
| 旧命令 | 推荐命令 | 详见 |
|--------|----------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc folder create` | `dws drive folder create` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
### 排版规范 / JSONML 参考
| 资源 | 用途 | 详见 |
|------|------|------|
| 创建工作流 | 标题、位置、骨架、回读校验 | [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) |
| 改写工作流 | 编辑形态优先级、分片 append、回读验收 | [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) |
| 排版规范 | 文档类型 / 骨架 / 元素边界 / 颜色语义 | [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) |
| JSONML 范例 | 所有节点类型的可复制命令 | [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) |
| JSONML 节点结构 | 字段定义 + JSON Schema | [`doc/format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) |
## 场景索引
> 任务驱动的入口:知道任务但不确定要读哪些命令文件时,按本表**一次性 Read 文件组**,不要逐个跳。
| 任务场景 | 一次性读取 | 主命令 |
|---------|-----------|--------|
| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` |
| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read |
| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create |
| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert |
| 局部改写 / 段落替换(保真) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | block update |
| 整篇 overwrite(可选并发检查) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | update |
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人 / 更新 / 删除) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws aisearch person` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
| 导出 PDF / DOCX / Markdown | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 导入本地文件为在线文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-import.md`](./doc/doc-import.md) | import |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
| 版本管理(保存/列出/回滚) | 本文件 §版本管理(**仅 adoc**) | `dws doc version save/list/revert`(adoc);其他文件类型当前 CLI 不支持版本管理 |
## 意图判断
用户说"找文档/搜文档/最近文档":
- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间)
- 空间内搜索 → `dws wiki node search --workspace <WS_ID> --query "<关键词>"`
- 遍历文件夹 → `dws drive list --workspace <WS_ID>` 或 `dws wiki node list --workspace <WS_ID>`
- 最近访问/最近编辑 → `dws drive recent`(见 [`drive.md`](../../dingtalk-drive/references/drive.md))
用户说"看文档/读内容/文档内容":
- 读取 → `read`(需文档 ID 或 URL)
- 元信息 → `info`
用户说"写文档/创建文档":
- 新建文字文档(adoc)→ `doc create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
- 指定父目录时,只有明确的文档文件夹 `nodeId` / `alidocs` 文件夹 URL 才能放入 `--folder`;上一步若只返回了纯数字 `dentryId`、`spaceId` 或 drive `parent-id`,不要把它传给 doc 的 `--folder`
> 严禁把「创建表格」路由到 `doc create`:
>
> - 用户说"创建表格/新建表格/建个电子表格/在线表格" → 走 [`dws sheet create`](../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(axls 在线电子表格)
> - 用户说"创建多维表格/新建 AI 表格/建 base/数据库表" → 走 [`dws aitable base create`](../../dingtalk-aitable/references/aitable.md#base-base-管理)(able 多维表格)
用户说"建文件夹/新建目录":
- 创建 → `dws drive folder create`
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `dws drive upload`(需本地文件路径)
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`:
- 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义)
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`**
- **任务查询**:`doc export` 内部自动轮询完成导出;仅在超时或中断后,用 `dws doc export get --job-id <jobId>` 手动查询任务状态
- 判断方法:
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export`
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download`
3. 不确定时,先用 `drive info --node <ID>` 查询节点信息,根据返回的 `extension` 字段判断:
- `extension` 为 `adoc` → 走 `doc export`
- `extension` 为其他(txt/pdf/docx/md/xlsx 等)→ 走 `drive download`
> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx 或 markdown 或 pdf,两者完全不同。
>
> **导出任务查询**:`doc export` 超时后可用 `dws doc export get --job-id <ID>` 查询任务状态。
用户说"导入文件/导入docx/导入Word/导入Excel/导入脑图/导入xmind/导入思维导图/把文件转成文档/文件转在线文档/导入到知识库/打开本地文件":
- **直接调用 `dws doc import --file <文件路径> --workspace <WORKSPACE_ID>`**(或 `--folder <FOLDER_ID>`),一条命令自动完成上传+转换+创建在线文档,**无需先读取文件内容再手动创建**。**注意:`--folder` 和 `--workspace` 至少传一个,不传会报错。导入到"我的文档"需先执行 `dws wiki space list --type myWikiSpace` 获取 workspaceId。**
- 支持格式与映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档
- 文件大小限制:20MB
- 导入后会自动转换为钉钉在线文档,返回在线文档 URL
- **任务查询**:`doc import` 内部自动轮询完成转换;仅在超时或中断后,用 `dws doc import get --task-id <taskId>` 手动查询任务状态
> **严禁先 Read 文件再 `doc create` + `doc update`**。`doc import` 是一体化命令,服务端负责格式转换,客户端无需解析文件内容。
>
> **导入任务查询**:`doc import` 超时后可用 `dws doc import get --task-id <ID>` 查询任务状态。
用户说"复制文档/拷贝文件/复制到":
- 复制 → `dws drive copy`
用户说"移动文档/搬到/移到/转移文件":
- 移动 → `dws drive move`
用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...":
- 重命名 → `dws drive rename --node <DOC_ID_OR_URL> --name "新名称"`
- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。
用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档":
- 删除节点 → `dws drive delete`
用户说"插入附件/上传附件到文档/往文档里加文件/加附件":
- 插入附件 → `media insert`(需文档 ID 或 URL + 本地文件路径)
用户说"插入图片/加图片/放张图/嵌入图片/往文档里插图":
- 插入图片 → `media insert`(需文档 ID 或 URL + 本地图片文件路径)
- 注意:图片也是通过 `media insert` 作为附件块插入文档,不是通过 `block insert`
用户说"下载附件/获取附件/取出文档里的附件":
- 下载附件 → `media download`(需文档 ID 或 URL + 资源 ID)
用户说"编辑块/改段落/插入标题/删除块":
- 查看结构 → `block list`
- 插入 → `block insert`
- 修改 → `block update`
- 删除 → `block delete`
用户说"保存版本/创建版本快照/手动保存一下版本":
> **前置守卫**:以下 `doc version` 命令仅适用于钉钉在线文档(adoc)。其他文件类型(txt/pdf/docx/xlsx/md 等)当前 CLI 不支持版本管理。
- 保存版本 → `version save`(需文档 ID 或 URL)
用户说"版本历史/版本列表/查看修改历史/历史版本":
- 查看版本列表 → `version list`(需文档 ID 或 URL)
用户说"回滚版本/恢复到某个版本/版本回退/回退到之前的版本":
- 回滚到指定版本 → `version revert`(需文档 ID 或 URL + 版本号,危险操作需确认)
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
- 新增权限 → `dws drive permission add`
- 修改权限 → `dws drive permission update`
- 查看谁有权限 → `dws drive permission list`
- 移除权限 → `dws drive permission remove`
> **关键区分**:
>
> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` probe;只有 probe 确认 `extension=adoc` 后,才继续按下列意图执行。其他类型(txt/pdf/docx/md/xlsx/file/folder 等)走 [`drive.md`](../../dingtalk-drive/references/drive.md) 或对应产品。
**用户直接粘贴文档 URL(无其他指令)**:
- 默认 → `read`(读取文档内容)
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
**用户粘贴 URL + 附加指令**:
- "帮我看看这个文档" → `read`
- "这个文档的信息" → `info`
- "往这个文档追加内容" → `update --mode append`
- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename`
- "把正文里的一级标题/章节标题改成 X" → `block update`
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(文件管理)
## 核心工作流
> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。
选定后只创建一次:
```bash
# ── 工作流 1: 定位并阅读文档 ──
dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search)
dws drive info --node <DOC_ID> --format json # 2. 必须确认 extension=adoc
dws doc read --node <DOC_ID> --format json # 3. 仅 adoc 读正文;其他 extension 切对应 skill
# ── 工作流 2: 创建文档(含分片自动写入)──
dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹
dws doc create --name "项目周报" --content-file /tmp/x.md \
--folder <DOC_FOLDER_NODE_ID> --format json # 2. 创建 + 写入
dws doc read --node <DOC_ID> --format json # 3. 回读校验(必须)
# ── 工作流 3: 局部改写(保真,首选 JSONML)──
dws doc block list --node <DOC_ID> --content-format jsonml # 1. 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID> # 2. 读子树
dws doc block update --node <DOC_ID> --block-id <UUID> \
--content-format jsonml --element '[...]' # 3. 写回(uuid 必须 == --block-id)
dws doc read --node <DOC_ID> --content-format jsonml # 4. 回读
# ── 工作流 4: 整篇 overwrite(仅在新骨架重写时)──
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json # 1. 读出当前 JSONML
# 修改 /tmp/doc.json 中的 jsonml 数组
dws doc update --node <DOC_ID> --content-file /tmp/doc.json \
--content-format jsonml --mode overwrite # 2. 写回(默认不做并发检查)
dws doc read --node <DOC_ID> --content-format jsonml # 3. 回读
# 担心被并发覆盖时,可加 --revision <N> 触发并发检查(详见 doc-update.md)
# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ──
dws drive upload --file ./report.pdf --folder <ID> # 上传作为独立文件(存储层)
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文(内容层)
# ── 工作流 6: 导入本地文件为在线文档 ──
dws doc import --file ./report.docx --workspace <WS_ID> --format json # 一体化导入(推荐)
dws doc import --file ./notes.md --folder <FOLDER_ID> --format json # 指定目标文件夹
dws doc import --file ./data.xlsx --workspace <WS_ID> --format json # 指定目标知识库
# ── 工作流 7: 下载 vs 导出(先 info 判 extension)──
dws drive info --node <NODE_ID> --format json # 必须先查 extension
dws doc export --node <NODE_ID> --output ~/downloads/ # extension=adoc 走 export(内容层)
dws drive download --node <NODE_ID> --output ~/downloads/ # extension≠adoc 走 drive download(存储层)
# ── 工作流 8: 评论 + 划词(@人 需 userId)──
dws aisearch person --keyword "张三" --dimension name --format json # 取 userId
dws doc comment create --node <DOC_ID> --content "请确认" --mention <uid> --format json
dws doc block list --node <DOC_ID> --format json # 划词需先取 blockId / paragraph.text
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json # 删除不可逆,必须先确认
# ── 工作流 9: 权限授予(节点级,已迁移到 drive)──
dws aisearch person --keyword "张三" --dimension name --format json # 取 userId
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 10: 文件操作(已迁移到 drive)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
# 方式 C:按文件夹遍历:dws drive list --workspace <WS_ID> --format json
# 第二步:执行
dws drive copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive rename --node <DOC_ID_OR_URL> --name "新名称" --format json
dws drive delete --node <DOC_ID_OR_URL> --yes --format json
dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报" --format json
```
禁止通过实际创建多个候选文档来预览模板。`+create-from-template --query` 仅保留兼容,不能作为新的 Agent Golden Route。
## 历史版本
```bash
# ── 工作流 11: 文档版本管理 ──
dws doc version save --node <DOC_ID> --format json # 1. 手动保存版本快照
dws doc version list --node <DOC_ID> --format json # 2. 查看版本列表(提取 version 号)
dws doc version revert --node <DOC_ID> --version <N> --yes --format json # 3. 回滚到指定版本(危险操作,需 --yes)
dws doc +version-save --node <DOC_ID> --format json
dws doc +version-list --node <DOC_ID> --limit 20 --format json
dws doc +version-revert --node <DOC_ID> --version <N> --format json
```
## 上下文传递表
`+version-save/list/revert` 分别用于快照、浏览和恢复,命中后直接执行,不预读 Help。`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。重要内容更新优先使用 `+checkpoint-update`,不要手工编排保存、写入和回读。回滚必须确认,以 leaf Schema 与 Runtime gate 为准。
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 |
## 权限与分享
| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node |
| `create` | `nodeId` | update / block 操作的 --node |
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) |
| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 |
| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 |
| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id |
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id |
| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 |
| `aisearch person` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user |
| `version list` | `version`(版本号) | version revert 的 --version |
| `version save` | 版本信息 | 确认保存成功 |
| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 |
| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) |
- 查询或聚合权限:`+inspect --include-permissions`。
- 新增、变更、移除权限:`+access-grant/+access-change/+access-revoke`。
- 授权后发链接:`+grant-and-share`。
- 姓名、群聊或组织 profile 多候选时必须停止消歧。
## 白板卡片与白板媒体资源
## 高级原子能力
创建文档内空白板前先确认目标文档,获得用户确认后执行:
以下能力在 shortcut 未公开所需参数时才使用原子 leaf:
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
- 特殊 JSONML block、白板或样式字段
- 需要原始 MCP 响应的诊断
- shortcut 明确返回 capability unavailable 的低频操作
返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于
`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时:
进入高级通道前只读取对应 leaf Schema 和一个精确 Reference。原子命令不是 shortcut 失败后的自动兜底,不能用来绕过权限、确认、类型或路径检查。
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
## 本地与跨产品边界
上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和
`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新
流程见 `dingtalk-misc` 的 `references/whiteboard.md`。
- 普通文件上传、下载、目录和文件树:`dingtalk-drive`
- 知识库空间与节点层级:`dingtalk-wiki`
- 原生 Markdown 文件:`dingtalk-misc`
- `axls` / `able`:对应电子表格或多维表 Skill
## 相关产品
- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
- [aitable](../../dingtalk-aitable/references/aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
- [drive](../../dingtalk-drive/references/drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
- [report](../../dingtalk-misc/references/report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
导出或媒体错误必须保留稳定 ID 后停止。禁止隐式执行 `curl/wget`、`pip/brew install`、Python Office 库、本地 OCR 或手写 HTTP 来伪造 DWS 任务结果。
@@ -1,268 +1,29 @@
# doc block(块级精细编辑:list / insert / update / delete)
# 块级编辑 Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为)
> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令)
> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义
>
> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例)
## 普通路径
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
---
## doc block list(查询块元素)
```
Usage:
dws doc block list [flags]
Example:
dws doc block list --node <DOC_ID>
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
dws doc block list --node <DOC_ID> --block-type heading
dws doc block list --node <DOC_ID> --content-format jsonml
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
Flags:
--node string 文档 ID 或 URL (必填)
--start-index int 起始位置 (从 0 开始)
--end-index int 终止位置 (含)
--block-type string 按块类型过滤
--content-format string 输出格式: 默认为 element,可选 jsonml(返回 JSONML 节点数组)
--block-id string 指定块 UUID(content-format=jsonml 时读取完整子树)
```
### content-format=jsonml 返回示例
每个 block 包含 `jsonml` 字段(JSON string,解析后为 JSONML 数组):
```json
{
"blocks": [
{
"blockId": "mpeurp5mj5o9xz4hnj",
"blockType": "h2",
"index": 0,
"jsonml": "[\"h2\",{\"uuid\":\"mpeurp5mj5o9xz4hnj\"},...]"
}
],
"totalCount": 5
}
```
---
## doc block insert(插入块元素)
```
Usage:
dws doc block insert [flags]
Example:
dws doc block insert --node <DOC_ID> --text "这是一段文字"
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
dws doc block insert --node <DOC_ID> --content-format jsonml --element '["p",{"uuid":"..."},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入引用块(blockquote)
dws doc block insert --node <DOC_ID> --element '{"blockType":"blockquote","blockquote":{"text":"这是一段引用内容"}}'
# 插入分栏块(columns):2 栏,children 为每栏的内容
dws doc block insert --node <DOC_ID> --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}'
# 插入表格(table):2 行 3 列
dws doc block insert --node <DOC_ID> --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# 插入行内图片(inline image):构造一个空 paragraph,在 children 中用 image elementType 指定图片 src
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{},"children":[{"elementType":"image","properties":{"src":"https://example.com/photo.png"}}]}'
# 插入附件块(attachment):resourceId 为资源ID,type 为 MIME 类型,viewType 可选 preview/summary
dws doc block insert --node <DOC_ID> --element '{"blockType":"attachment","attachment":{"resourceId":"12345-xxx-xxx-123-xxxxx","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}'
# 插入分割线:使用 doc update --mode append 以 Markdown 格式追加
dws doc update --node <DOC_ID> --content "---" --mode append
Flags:
--node string 文档 ID 或 URL (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 参照位置索引 (从 0 开始)
--where string 插入方向: before / after (默认 after)
--ref-block string 参照块 ID (优先级高于 --index)
```
> **content-format=jsonml 完整范例**:参见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md),包含所有节点类型的正确 insert/update 命令示例。
---
## doc block update(更新块元素)
```
Usage:
dws doc block update [flags]
Example:
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format jsonml --element '["h1",{"uuid":"<BLOCK_ID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新标题"]]]'
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。
---
## doc block delete(删除块元素)
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc block delete [flags]
Example:
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
```
---
## JSONML 格式的块操作(首选路径)
使用 `--content-format jsonml` 可以直接以 JSONML 节点格式进行块操作,覆盖所有节点类型(不限于 block element 定义的有限类型)。
先读取最小必要范围并取得稳定 block ID:
```bash
# 列出顶层 JSONML 节点
dws doc block list --node DOC_ID --content-format jsonml
# 读取指定 uuid 的完整子树
dws doc block list --node DOC_ID --content-format jsonml --block-id UUID
# 插入 JSONML 节点(同级定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--ref-block UUID --where after
# 插入 JSONML 节点(容器内定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--parent-block UUID --index 2
# 整体替换(update 时 uuid 必须等于 --block-id)
dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \
--element '["p", {"uuid": "UUID"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "修改后内容"]]]'
# 删除(无需 format 区分)
dws doc block delete --node DOC_ID --block-id UUID
dws doc +fetch --node <DOC_ID> --detail with-ids --scope section --start-block-id <KNOWN_BLOCK_ID> --format json
```
> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
## 关键说明
- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。
- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。
- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。
- **JSONML validator**(写入端默认行为):
- 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
- `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。
- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。
- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| element 模式的 `blocks[].element.id`;JSONML 模式的 `blocks[].blockId` | `block insert` 的 `--ref-block`、`block update/delete` 的 `--block-id`;空文档占位空段落不要作为 `--ref-block` |
| `blocks[].element.id` | [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--block-id` |
| `blocks[].element.paragraph.text` | 计算 [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--start` / `--end` 偏移量 |
| attachment 块的 `resourceId` | [`./doc-media.md`](./doc-media.md) `media download` 的 `--resource-id` |
## 常用模板
再按意图使用统一更新入口:
```bash
# ── element JSON 形态(次选,老接口)──
# 文本段落
dws doc block insert --node <DOC_ID> --text "这是段落文字" --content-format element
# 标题(level 1-6)
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2 --content-format element
# 引用块
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"blockquote","blockquote":{"text":"引用内容"}}'
# 分栏(2 栏)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏"}},{"blockType":"paragraph","paragraph":{"text":"右栏"}}]}'
# 表格(2x3)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# Callout(element JSON 次选;首选 JSONML container[subType=colorBlocks])
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"callout","callout":{"emoji":"⚠️","bgColor":"#FDE2E0","content":[{"text":"高风险操作,先备份"}]}}'
# 在指定 block 之前插入
dws doc block insert --node <DOC_ID> --text "在此之前" --ref-block <BLOCK_ID> --where before --content-format element
# 修改某块文本
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后" --content-format element
# 修改标题
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format element \
--element '{"blockType":"heading","heading":{"text":"新标题","level":2}}'
# 删除(用户确认后)
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
# ── JSONML 形态(首选)──
# 列出 + 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml
# 读单个 block 完整子树
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
# 插入段落(同级定位)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入 callout(colorBlocks)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"co1","subType":"colorBlocks","metadata":{"bgcolor":"#FDE2E0","border":"#F5C2C7"}},["p",{"uuid":"co1p1"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"高风险操作,先备份"]]]]'
# 插入分栏(columns / column)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"cols1","subType":"columns","metadata":{"size":"2"}},["container",{"uuid":"cols1c1","subType":"column"},["p",{"uuid":"cols1c1p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"左栏"]]]],["container",{"uuid":"cols1c2","subType":"column"},["p",{"uuid":"cols1c2p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"右栏"]]]]]'
# 整体替换(update 的 uuid 必须等于 --block-id)
dws doc block update --node <DOC_ID> --block-id <UUID> --content-format jsonml \
--element '["p",{"uuid":"<UUID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"修改后内容"]]]'
# 容器内定位插入(指定 parent + index)
dws doc block insert --node <DOC_ID> --content-format jsonml --parent-block <UUID> --index 2 \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"在容器第 3 项位置插入"]]]'
dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"
dws doc +update --node <DOC_ID> --command block_insert_after --after-block-id <BLOCK_ID> --content "补充内容"
dws doc +update --node <DOC_ID> --command block_delete --block-id <BLOCK_ID>
```
## 参考
`block-id` 必须来自真实 `+fetch --detail with-ids`、`+review` 或原子 block 列表返回。确认、写入与验证统一由 `+update` 处理,正常成功不追加整篇回读。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例)
- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范)
## 富结构专家路径
只有需要 shortcut 未公开的 callout、分栏、复杂表格或 JSONML element 参数时:
1. 按需读取 [JSONML schema](format/doc-jsonml-schema.md) 或 [cookbook](format/doc-jsonml-cookbook.md),不要两者都预加载。
2. 读取精确 `doc block` leaf Schema,确认当前 flags。
3. 用原子 block 命令只改目标块;JSONML update 的 uuid 必须等于目标 block ID。
图片和附件不得手写临时 URL 或 OSS 请求,统一走 [`doc-media.md`](doc-media.md) 的 `+media-insert/+media-download`。删除与覆盖的确认以 Runtime gate 为准,示例不得预填 `--yes`。
@@ -1,203 +1,29 @@
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
# 文档评论 Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
>
> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ `dws chat search`(查群用 openConversationId)/ [`doc-block.md`](./doc-block.md)(划词评论必须先取 blockId 与 paragraph 文本)
---
## doc comment list(查询文档评论列表)
```
Usage:
dws doc comment list [flags]
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
---
## doc comment create(创建全文评论)
```
Usage:
dws doc comment create [flags]
Example:
dws doc comment create --node <DOC_ID> --content "这里需要修改"
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
dws doc comment create --node <DOC_ID> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔
```
---
## doc comment reply(回复评论)
```
Usage:
dws doc comment reply [flags]
Example:
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内确认" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔;不得与 --emoji 同时使用
```
---
## doc comment update(更新评论)
```
Usage:
dws doc comment update [flags]
Example:
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "更新后的评论内容"
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填)
--content string 更新后的评论文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔
```
---
## doc comment delete(删除评论)
> **CAUTION:** 不可逆操作。必须先向用户确认。用户明确同意后,命令加 `--yes` 执行;不加 `--yes` 时 CLI 会在终端中提示输入 `yes/no`。
```
Usage:
dws doc comment delete [flags]
Example:
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填)
```
---
## doc comment create-inline(创建划词评论)
> **能力边界:** `create_inline_comment` 后端当前未提供 `mentionedOpenConversationIds`,因此 `create-inline` 不支持 @群。不得只在正文写 `@群名` 冒充真实群 mention;如果用户要求划词评论 @群,应明确提示暂不支持。
```
Usage:
dws doc comment create-inline [flags]
Example:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--block-id string 评论标记所在的块 ID,可通过 dws doc block list 获取 (必填)
--start int 评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填)
--end int 评论标记在块内文本中的结束字符偏移量,必须大于 start (必填)
--content string 评论的文字内容,纯文本 (必填)
--selected-text string 选中文本的内容,填写后评论列表中会展示「引用原文:xxx」
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。
- `--mentioned-open-conversation-id` 接受群 `openConversationId`,可重复传入或逗号分隔;CLI 稳定去重并拒绝空白值,不会转换成内部 `cid`。
- 群 mention 仅支持全文评论 `create`、`update`、`reply`;`create-inline` 后端当前未提供该字段,不支持 @群。
- 真正的用户 mention 必须传 `mentionedUserIds`,真正的群 mention 必须传 `mentionedOpenConversationIds`。只在 `content` 写 `@名称` 是普通文本,不会创建 mention 节点或通知。
- 用户直接给 openConversationId 时直接使用。用户给群名时,使用当前 DWS profile 执行 `dws chat search --query "<群名>" --format json`。只在唯一匹配时取 `openConversationId`;多个同名候选必须展示群名称、组织等区分信息让用户选择;无匹配或查询失败时不得调用评论接口。
- 不按所属组织在客户端过滤或提前拒绝 mention,最终权限以评论接口为准。服务端失败时保留或准确概括 `errorCode`、`errorMessage`、`logId`,不得删除 mention 参数静默重试;若要降级为普通文本必须先征得用户同意。
- `content` 只保留正文。仅移除已成功解析成真实 mention 的明确 `@群名` token,保持原换行;普通 `@` 文本或未解析名称不得误删。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取,用于 `reply` / `update` / `delete`。
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `reply --emoji` 与群 mention 冲突;CLI 会在调用服务端前报错,不会静默忽略。
- `delete` 是不可逆操作;AI Agent 必须先让用户确认,再追加 `--yes`,避免 CLI 进入交互等待。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
| `dws aisearch person` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
| `dws chat search` 的 `openConversationId` | `comment create/update/reply` 的 `--mentioned-open-conversation-id` |
## 常用模板
## Review 聚合
```bash
# 查看文档全部评论
dws doc comment list --node <DOC_ID> --format json
# 仅看未解决的划词评论
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved --format json
# 创建全文评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 创建评论 + @人(先 aisearch person 拿 userId)
dws aisearch person --keyword "张三" --dimension name --format json
# 提取 userId 后:
dws doc comment create --node <DOC_ID> --content "请确认这部分" --mention <uid1>,<uid2> --format json
# 创建评论 + @群(搜索结果唯一后取 openConversationId)
dws chat search --query "项目群" --format json
dws doc comment create --node <DOC_ID> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID> --format json
# 文字回复
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 回复 + @群
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内确认" --mentioned-open-conversation-id <OPEN_CID> --format json
# 更新评论
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
# 更新 + 同时 @用户和 @群
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请大家确认" --mention <UID> --mentioned-open-conversation-id <OPEN_CID> --format json
# 删除评论(不可逆;必须用户确认后再加 --yes)
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
# 表情回复(--content 填表情名称)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
dws doc block list --node <DOC_ID> --format json
# 计算偏移后:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
# 划词评论 + 引用原文 + @人
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <uid1>,<uid2> --format json
dws doc +review --node <DOC_ID_OR_URL> --format json
```
## 参考
需要一次看到未解决评论、引用原文和 block 上下文时优先使用 `+review`。从真实返回取得 `commentKey` 和 `blockId`,禁止按数组位置或猜测 ID。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(取 blockId 与块内文本以计算划词偏移)
- `dws aisearch person`(取 mention 用的 userId,跨产品命令)
## 精确评论动作
```bash
dws doc +comment-list --node <DOC_ID> --resolve-status unresolved --limit 20 --format json
dws doc +comment-list --node <DOC_ID> --limit 20 --cursor <NEXT_TOKEN> --format json
dws doc +comment-create --node <DOC_ID> --content "这里需要补充证据"
dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"
dws doc +comment-reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已补充"
dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "修订后的意见"
dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>
```
- 全文与划词评论统一使用公开的 `+comment-create`:优先传唯一 `--selection`;已知真实 block 时传 `--block-id --start --end`,CLI 自动回读并校验 `selectedText`。不要选用未公开的 `+comment-create-inline`。
- `--mention` 传单个 uid 或逗号分隔列表,例如 `--mention 550582,123456`;不要传 JSON 数组。
- 列表用 `--limit` 控制页大小(兼容 `--page-size`),有 `nextToken` 时原样传给 `--cursor`;不能把单页当作全部评论。
- 创建、回复、删除等写操作执行前消费 leaf Schema `confirmation`;需确认时先询问,示例不得预填 `--yes`。
- 删除不可恢复,必须核对 node 与 commentKey。部分或未知结果不得自动重试。
只有 shortcut 未公开必要字段时,才读取精确原子 leaf Schema;不要加载整份评论参考或产品 Catalog 来猜参数。
@@ -1,96 +1,42 @@
# doc create(创建文档)
# 创建在线文字文档
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md) — 创建工作流(标题、位置、骨架、回读校验)
> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范(草稿元素清单、骨架样板)
> 4. [`./doc-update.md` §内容写入管道](./doc-update.md) — 长内容自动分片、`--content-file` vs `--content` 选择
> 5. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
本页只处理钉钉在线文字文档(`adoc`)创建。普通文件上传走 `dingtalk-drive`,表格和多维表分别走对应产品。
## 创建路由前置判断(必看)
> `dws doc create` 只能创建在线文字文档(adoc),**不要**用它承接所有「新建 xxx」请求。收到「创建/新建」类需求时,必须先按文件类型分流:
>
> - 用户说「创建表格 / 新建表格 / 建个电子表格 / 在线表格 / 销售数据表」等 → 走 [`dws sheet create`](../../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(钉钉在线电子表格 `axls`),**不要**走 `doc create`
> - 用户说「创建多维表格 / 新建 AI 表格 / 建个 base / 数据库表」等 → 走 [`dws aitable base create`](../../../dingtalk-aitable/references/aitable.md#base-base-管理)(多维表格 `able`),**不要**走 `doc create`
> - 用户说「创建文档 / 新建文档 / 写篇文档 / 会议纪要 / 周报 / 方案」等文字型内容 → 才走 `dws doc create`
>
> 一句话口诀:表格 → sheet/aitable;文档 → doc。
## 命令格式
```
Usage:
dws doc create [flags]
Example:
dws doc create --name "项目周报"
dws doc create --name "Q1 总结" --content "# Q1 总结" --folder <DOC_FOLDER_NODE_ID>
dws doc create --name "知识库文档" --workspace <WS_ID>
dws doc create --name "周报" --content-file ./weekly.md --folder <DOC_FOLDER_NODE_ID>
cat report.md | dws doc create --name "月报" --content -
Flags:
--name string 文档名称 (必填)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID
--content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append;调用方无需手动编排。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `nodeId` | [`./doc-update.md`](./doc-update.md) / [`./doc-block.md`](./doc-block.md) / [`./doc-media.md`](./doc-media.md) 的 `--node` |
| `docUrl` | 最终交付给用户的链接;缺失时用 [`./doc-info.md`](./doc-info.md) 补查 |
| `chunksWritten` | 判断是否触发自动分片;> 1 时重点检查章节顺序 |
## 回读验收(必读)
CLI **不会**自动回读校验。**每次创建后**都必须执行 `doc read --node <nodeId>` 校验关键标题、段落首句、表格表头是否完整。详见 [`./style/doc-create-workflow.md` «回读验收»](./style/doc-create-workflow.md)。
## 常用模板
## 唯一推荐入口
```bash
# 默认创建到「我的文档」根目录(推荐文件路径)
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --content-format markdown
# 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --folder <DOC_FOLDER_NODE_ID> --content-format markdown
# 创建到知识库
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --workspace <WS_ID> --content-format markdown
# 创建空文档(仅取 nodeId 后再分步写入,适合 >200KB 兜底)
dws doc create --name "<文档名>" [--folder <ID> | --workspace <ID>] --content-format markdown
# 短纯文本字面量(< 2KB 且无换行/表格才允许)
dws doc create --name "<文档名>" --content "短内容" --content-format markdown
# stdin(heredoc / pipe)
cat report.md | dws doc create --name "月报" --content - --content-format markdown
# JSONML 起稿(决策型 / 对展示效果有要求时直接用 JSONML 构造)
# 详见 doc-create-workflow.md §JSONML 起稿判定
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml
# JSONML 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml --folder <DOC_FOLDER_NODE_ID>
dws doc +create --name "<文档名>" [--content "短文本"] [--folder <ID> | --workspace <ID>] --format json
dws doc +create --name "<文档名>" --content @body.md --format json
dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --format json
```
## 参考
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-update.md`](./doc-update.md)(写入管道、长 markdown、追加段落、回读补救)
- [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md)(创建流程 + 回读验收)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(草稿排版规范)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构与范例)
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
```json
{
"status": "success",
"complete": true,
"data": {
"nodeId": "...",
"verified": true
}
}
```
## 结果处理
- `status=success` 且 `verified=true`:可以报告创建完成,并保留真实 `nodeId`/URL。
- `status=partial_success`:文档或部分分片已经创建;按 `steps` 回读现状,禁止重跑整条创建。
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
## 高级通道
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
复杂排版按需读取 [doc-style-guideline.md](style/doc-style-guideline.md);命令选路仍以本页的 `+create` 为准。
@@ -1,110 +1,30 @@
# doc export(在线文档导出为 docx/markdown/pdf)
# 导出在线文档
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> **路由前置判断**:用户说「下载/导出」时**必须**先用 `dws drive info --node <ID> --format json` 查 `extension`:
> - `extension` 为 `adoc`(在线文档)→ **必须用 `export`**,禁止用 `download`
> - `extension` 为其他(txt/pdf/docx/md/xlsx 等)→ 用 `dws drive download`(详见 [`../drive.md`](../../../dingtalk-drive/references/drive.md))
>
> `drive download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx、markdown 或 pdf,两者完全不同。
---
## doc export(一体化命令)
```
Usage:
dws doc export [flags]
Example:
dws doc export --node "https://alidocs.dingtalk.com/i/nodes/xxx" --output ./exported.docx
dws doc export --node <DOC_ID> --output ~/downloads/
dws doc export --node <DOC_ID> --export-format markdown --output ./exported.md
# 异步模式:只提交不等待,立即返回 jobId
Flags:
--node string 要导出的文档标识,支持文档 URL 或 dentryUuid (必填)
--output string 本地保存路径,文件路径或目录 (必填)
--export-format string 导出格式,支持 docx(默认)、markdown(或 md)和 pdf
```
CLI 内部自动完成:提交导出任务 → 渐进式退避轮询(最多约 5 分钟)→ 成功后自动下载文件。
**只需一条命令,无需手动轮询。**
**任务查询**:`doc export` 内部自动轮询直到完成,通常不需要手动查询;仅在超时或中断后用 `dws doc export get --job-id <jobId>` 兜底查询。
---
## doc export get(手动兜底查询任务)
> 任务状态:PROCESSING(处理中)/ SUCCESS(导出成功,返回 downloadUrl)/ FAILED(导出失败)。
```
Usage:
dws doc export get [flags]
Example:
dws doc export get --job-id <JOB_ID>
Flags:
--job-id string 导出任务 ID (必填)
```
仅在 `dws doc export` 超时或中断后,用于手动查询任务状态。通常不需要调用。
## 关键说明
- `export` 是一体化命令,一条命令自动完成提交→轮询→下载,**无需手动编排轮询**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。
- `export` 超时或中断后,CLI 会输出 `jobId`,可用 `dws doc export get --job-id <jobId>` 手动查询任务状态。
- `export` 支持钉钉在线文档(alidocs,`contentType=ALIDOC`)导出为 `docx`、`markdown` 或 `pdf`,**在线表格导出请使用其他命令**。
- `--export-format` 支持 `docx`(默认)、`markdown`(或 `md`)和 `pdf` 三种格式。
- `--output` 既可以是文件完整路径,也可以是目录(CLI 自动按文档名生成 `.docx`、`.md` 或 `.pdf`)。
- `doc export get` 主参数为 `--job-id`。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `localPath` | 用户可访问的本地文件路径(同步模式) |
| 超时/中断时返回的 `jobId` | `export get` 的 `--job-id`(查询后取 downloadUrl 获取下载地址) |
## 常用模板
## 唯一推荐入口
```bash
# 一体化导出为 docx(最常用)
dws doc export --node <DOC_ID> --output ./exported.docx
# 导出为 markdown
dws doc export --node <DOC_ID> --export-format markdown --output ./exported.md
# 使用 md 缩写导出为 markdown
dws doc export --node <DOC_ID> --export-format md --output ./exported.md
# 导出为 pdf
dws doc export --node <DOC_ID> --export-format pdf --output ./exported.pdf
# 输出到目录(自动按文档名命名,docx 格式)
dws doc export --node <DOC_ID> --output ~/downloads/
# 输出到目录(markdown 格式,自动生成 .md 扩展名)
dws doc export --node <DOC_ID> --export-format markdown --output ~/downloads/
# alidocs URL 直传
dws doc export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ./exported.docx
# 兜底:超时或中断后手动查任务
dws doc export get --job-id <JOB_ID> --format json
dws doc +export --node <DOC_ID_OR_URL> --export-format docx --output ./exports/ --format json
dws doc +export --node <DOC_ID_OR_URL> --export-format markdown --output ./document.md --format json
dws doc +export --node <DOC_ID_OR_URL> --export-format pdf --output ./document.pdf --format json
```
## 输出规范
`+export` 一次完成提交、轮询和原子下载。输出路径必须位于工作目录内,默认 no-clobber;只有用户明确允许覆盖时使用 `--overwrite`。
导出完成后的输出必须遵循以下规则:
`--export-format` 必填;全局 `--format json` 只控制 CLI 输出,不能代替业务导出格式。禁止依赖默认 docx,也不要猜测 `--type`。
- **仅返回本地文件路径**:导出成功后,直接告知用户文件已保存到本地的具体路径
- **禁止上传 CDN**:导出完成后不要调用任何上传命令将文件上传到钉钉 CDN
- **禁止输出下载链接**:不得生成或展示 `down.dingtalk.com` 等 CDN 下载链接
- 正确示例:`文档已导出到: ./文档名.docx`
- 错误示例:上传文件并展示 CDN 下载链接
异步状态 `INIT` 与 `PROCESSING` 都表示任务仍可继续轮询;只有终态失败才停止,不能把 `INIT` 当导出失败。
## 参考
## 类型边界
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType=ALIDOC 才走 export)
- [`../drive.md`](../../../dingtalk-drive/references/drive.md)(非 ALIDOC 文件用 `dws drive download`)
- 在线文字文档(`adoc`)转为 docx/markdown/pdf:`+export`。
- 已存在的普通文件原样下载:切换到 `dingtalk-drive`,使用 drive download。
- 不要为了导出先把正文读到本地再重新生成文件。
## 失败处理
- 提交前权限/认证失败:原样报告并停止;不要尝试同义底层命令。
- 已返回 `jobId` 后轮询失败或超时:保留 `jobId`,只用 `+export-get --job-id <JOB_ID>` 恢复查询,禁止重新提交导出。
- 下载阶段失败:保留 `jobId` 和目标相对路径,用 `+export-get --job-id <JOB_ID> --output ./目标文件` 通过同一安全下载器恢复;不要直接 `curl` 临时 URL。
- 禁止安装 `pandoc`、`python-docx` 或其他依赖来隐式伪造导出结果。只有用户明确改成“本地生成文件”任务时,才可作为一个新的独立工作流处理。
只有需要显式接管异步 job 的恢复场景才使用 `+export-submit/+export-get`;正常导出不得手工编排它们。
@@ -1,131 +1,27 @@
# doc import(本地文件导入为在线文档)
# 导入本地文件:`+import` Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> **支持的文件格式**:docx, doc, xlsx, xls, md, txt, xmind, mark
> **文件大小限制**:20MB
> **不支持转换的格式**:白名单外的所有格式(html/pdf/zip/无扩展名等)不报错,CLI 自动改走文件上传链路,以原文件形式存入 `--folder`/`--workspace` 指定的文档空间/知识库位置(同样受 20MB 上限约束);JSON 结果带 `fallback=upload`、`converted=false` 标记,表示产出文件对象而非在线文档;需要"可编辑在线文档"时先把内容转为 md 再导入;目标是钉盘时直接用 `dws drive upload`。
---
## doc import(一体化命令)
```
Usage:
dws doc import [flags]
Example:
dws doc import --file ./report.docx --workspace <WORKSPACE_ID>
dws doc import --file ./notes.md --folder <FOLDER_ID>
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID>
dws doc import --file ./draft.md --workspace <WORKSPACE_ID> --name "项目周报"
Flags:
--file string 本地文件路径 (必填)
--folder string 目标文件夹 ID 或 URL (与 --workspace 必填其一)
--workspace string 目标知识库 ID 或 URL (与 --folder 必填其一)
--name string 导入后文档名称 (可选,默认取文件名不含扩展名)
```
> 注意:`--folder` 接受文件夹的 dentryUuid(nodeId)或 alidocs 文件夹 URL。
> 如果通过 `dws doc info` 获取信息,应使用返回的 `nodeId` 字段,
> 而非 `folderId`(后者是父文件夹 ID)。
> 当 URL 指向的节点 `nodeType` 不是 "folder" 时,应提示用户检查链接。
CLI 内部自动完成:创建导入会话 → 上传文件到 OSS → 确认导入触发转换 → 渐进式退避轮询(最多约 5 分钟)。
**只需一条命令,无需手动轮询。**
---
## doc import get(手动兜底查询任务)
> 任务状态:processing(转换中)/ completed(导入成功,返回 documentUrl)/ failed(导入失败)。
```
Usage:
dws doc import get [flags]
Example:
dws doc import get --task-id <TASK_ID>
Flags:
--task-id string 导入任务 ID (必填)
```
仅在 `dws doc import` 超时或中断后,用于手动查询任务状态。通常不需要调用。
**输出示例**(统一 TaskResult 格式):
```json
{"id":"task-abc123","type":"import","status":"SUCCESS","resultUrl":"https://alidocs.dingtalk.com/i/nodes/xxx","message":"","createTime":"2024-01-15T10:30:00Z"}
```
**处理中示例**:
```json
{"id":"task-abc123","type":"import","status":"PROCESSING","resultUrl":"","message":"任务处理中,请稍后查询","createTime":"2024-01-15T10:30:00Z"}
```
## 关键说明
- `import` 是一体化命令,一条命令自动完成创建会话→上传→确认→轮询,**无需手动编排**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。
- `import` 超时或中断后,CLI 会输出 `taskId`,可用 `dws doc import get --task-id <taskId>` 手动查询任务状态。
- 支持的文件格式:docx, doc, xlsx, xls, md, txt, xmind, mark(共 8 种)。
- 白名单外格式(html/pdf/zip/无扩展名等)不报错:自动改走文件上传链路,以原文件形式存入目标文档空间/知识库(stderr 有改道提示,同样受 20MB 上限约束),JSON 结果带 `fallback=upload`、`converted=false` 标记——返回文件对象而非在线文档,不得报告为"已转换";要"可编辑在线文档"时先转 md 再导入,目标是钉盘时用 `dws drive upload`。
- 文件大小限制:20MB。超过限制时 CLI 会直接报错,不会发起网络请求。
- `--folder` 和 `--workspace` 至少传一个,不传会报错。如需导入到"我的文档"根目录,请先获取 workspaceId:`dws wiki space list --type myWikiSpace --format json`,然后传 `--workspace <workspaceId>`。
- `--folder` 优先级高于 `--workspace`:两者都传时以 `--folder` 为准。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `documentUrl` | 导入完成后的在线文档地址(可直接分享给用户) |
| `documentName` | 导入后的文档名称 |
| `documentType` | 文档类型(DOC / SHEET / MIND) |
| 超时/中断时返回的 `taskId` | `import get` 的 `--task-id`(查询后取 documentUrl 获取文档地址) |
## 输出规范
导入完成后的输出必须遵循以下规则:
- **返回在线文档 URL**:导入成功后,直接告知用户文档已导入并提供在线文档链接
- **输出格式**:`文件已导入为在线文档: <documentUrl>`
- 正确示例:`文件已导入为在线文档: https://alidocs.dingtalk.com/i/nodes/xxx`
## 格式与文档类型映射
| 文件扩展名 | 导入后文档类型 | 说明 |
|-----------|--------------|------|
| `.docx` / `.doc` | DOC(文字文档) | Word 文档导入为在线文档 |
| `.xlsx` / `.xls` | SHEET(电子表格) | Excel 文件导入为在线表格 |
| `.md` / `.txt` | DOC(文字文档) | Markdown/纯文本导入为在线文档 |
| `.xmind` | MIND(脑图) | XMind 文件导入为在线脑图 |
| `.mark` | MIND(脑图) | Mark 格式导入为在线脑图 |
## 常用模板
## 唯一推荐入口
```bash
# 导入 Word 文档(最常用)
dws doc import --file ./report.docx --workspace <WORKSPACE_ID> --format json
# 导入 Markdown 文件
dws doc import --file ./notes.md --workspace <WORKSPACE_ID> --format json
# 导入到指定文件夹
dws doc import --file ./data.xlsx --folder <FOLDER_ID> --format json
# 导入到知识库根目录
dws doc import --file ./plan.docx --workspace <WORKSPACE_ID> --format json
# 自定义文档名称
dws doc import --file ./draft.md --workspace <WORKSPACE_ID> --name "项目方案" --format json
# 导入 XMind 脑图文件
dws doc import --file ./mindmap.xmind --workspace <WORKSPACE_ID> --format json
# 兜底:超时或中断后手动查任务
dws doc import get --task-id <TASK_ID> --format json
# 异步模式:提交后立即返回,不等待格式转换完成
dws doc +import --file ./report.docx --format json
dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要" --format json
```
## 参考
`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:理解文档标识)
## 本地文件边界
- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。
- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。
- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。
## 失败处理
- 发起前的格式、大小或路径校验失败:修正输入后再执行。
- 已返回 `taskId` 后超时或中断:保留该 `taskId`,读取精确恢复命令 Schema 后只查询原任务;禁止重新提交导入。
- 返回状态未知时原样报告,不把本地文件内容改走 `+create`,因为这会改变格式保真和任务语义。
- 白名单外格式如果目标是钉盘而非文档空间,切换到 `dingtalk-drive` 上传。
正常导入不得手工编排原子 `doc import` 子步骤。只有 shortcut 未公开必要的恢复参数时,才按精确 leaf Schema 使用原子查询命令。
@@ -1,139 +1,12 @@
# doc info(获取文档元信息 + URL 解析)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`url-patterns.md`](../../../dingtalk-shared/references/url-patterns.md) — 仅当用户原始 `alidocs` URL 需要 probe 时
>
> **探测入口变更**:alidocs URL 的类型探测现在统一走 `dws drive info`(详见 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程))。`drive info` 检测到 `extension=adoc/axls/able` 时会自动调用 `doc info` 返回更详细的文档信息。**仅在 `drive info` 已确认是 ALIDOC 类型后**,才需要直接使用 `doc info`。
>
> **同任务常配合**:`dws drive search` / `dws wiki node search`(先定位 nodeId)/ [`doc-read.md`](doc-read.md)(确认是 ALIDOC 后读正文)
## 命令格式
```
Usage:
dws doc info [flags]
Example:
dws doc info --node <DOC_ID>
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
Flags:
--node string 文档 ID 或 URL (必填)
```
## URL 识别与 DOC_ID 提取
当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。
补充:如果这是用户直接提供的原始 `alidocs` URL,必须先按 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` 探测真实类型。只有 `extension=adoc` 时才继续走 `doc`;其他类型(pdf/docx/txt/md/xlsx 等)走 `drive`。
### 支持的 URL 格式
| 格式 | 示例 | DOC_ID 提取方式 |
|------|------|----------------|
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
### 提取规则
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
4. 对用户直接提供的原始 `alidocs` URL,先按 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` 探测;只有探测确认 `extension=adoc` 时,才继续走 `doc`
## ID 边界与参数映射
- `nodeId` 是 `doc` 命令的统一节点标识;文档、文件夹、文件都通过 `nodeId` 或完整 `alidocs` URL 传给 `--node` / `--folder`。
- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。
- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。
- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder <folderNodeId或folderUrl>`,目标知识库使用 `--workspace <workspaceId或workspaceUrl>`。
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `dws drive search` / `dws drive list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
## 处理流程
```
用户输入含 alidocs.dingtalk.com URL
→ 若是用户直接提供的原始 URL,先按链接规范做 probe
→ 提取 DOC_ID(URL 路径最后一段)
→ 结合用户意图选择命令(adoc 默认 `doc read`,folder 默认 `drive list`,file 默认 `drive download`)
→ 将 DOC_ID 传给 --node 参数
```
## 获取 nodeId 的三种方式(按场景选择,**无需全部执行**)
执行 `read` / `info` / `update` / `copy` / `move` / `rename` / `delete` / `block` 等所有需要 `--node` 的命令前,按以下顺序确定 nodeId 来源,**命中即停**,不要走多余的 search/list 浪费一次调用:
| 方式 | 触发条件 | 操作 |
|------|----------|------|
| **A** | 用户**直接提供文档 URL 或 nodeId** | **直接传给 `--node`**,无需额外查询;优先使用此方式 |
| **B** | 用户给出关键字 / 文档名 | `dws drive search --query "<关键字>" --format json` 或 `dws wiki node search --workspace <WS_ID> --query "<关键字>"` 从返回中提取 nodeId |
| **C** | 用户指向某个文件夹下的文档 | `dws drive list --workspace <WS_ID> --format json` 或 `dws wiki node list --workspace <WS_ID>` 从返回中提取 |
> **关键节省**:方式 A 命中时,禁止再调 search/list "确认一下" —— 用户提供的 URL/nodeId 本身就是权威输入。同理,`--folder` 也支持 alidocs 文件夹 URL 直传,不要先 search 把 URL 解析成纯数字 ID 再传。
## nodeId 多格式说明
所有 `--node` 参数同时支持以下格式,系统自动识别:
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用)
以下命令效果相同:
# 查看文档信息:`+inspect` Golden Route
```bash
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE"
dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW"
dws doc +inspect --node <DOC_ID_OR_URL> --format json
dws doc +inspect --node <DOC_ID_OR_URL> --include-permissions --include-history --format json
```
> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。
只打开任务需要的 `--include-style/--include-permissions/--include-history/--include-media/--include-comments`,不要默认全取。正文读取使用 `+fetch`,不要用信息查询替代正文接口。
`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。
检查返回的真实 `nodeId`、类型、URL 和各可选步骤。若 URL 指向的不是在线文字文档,停止 doc 流程并切换到 drive、sheet、aitable、slides 或 wiki;禁止凭 URL 外形猜类型。
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws drive search` / `dws drive list` 找到文档文件夹 nodeId。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `extension` | 选择 [`./doc-read.md`](doc-read.md)(adoc)/ `dws sheet ...`(axls)/ `dws aitable ...`(able)/ `dws drive download`(其他) |
| `nodeId` / `docUrl` | 后续所有 `--node` 入参 |
## 常用模板
```bash
# 标准用法(nodeId 直传)
dws doc info --node <DOC_ID> --format json
# alidocs URL 直传(CLI 自动解析)
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# document/edit URL 直传(必须完整 URL,禁止单独提取 dentryKey)
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>" --format json
# document/preview URL 直传
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>" --format json
```
**反例**(这些 ID **不能**直接用于 `--node`):
```bash
# ❌ 纯数字 dentryId(drive 链路)
dws doc info --node 1234567890123 # 错误
# ❌ 单独的 dentryKey(必须带完整 URL)
dws doc info --node "wo1g3x54FzVEJ5yE" # 错误
# ❌ workspaceId(应作为 --workspace 用)
dws doc info --node <WS_ID> # 错误
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- `dws drive search` / `dws wiki node search`(前置:定位 nodeId 的搜索入口,详见 [`../drive.md`](../../../dingtalk-drive/references/drive.md) / [`../wiki.md`](../../../dingtalk-wiki/references/wiki.md))
- [`./doc-read.md`](doc-read.md)(extension=adoc 的后续命令)
- [`url-patterns.md`](../../../dingtalk-shared/references/url-patterns.md)(用户原始 alidocs URL 的 probe 流程)
只有 `+inspect` 未公开所需的底层字段或必须获得原始响应时,才读取精确 leaf Schema 后使用原子信息命令。
@@ -1,98 +1,34 @@
# doc media(附件 / 图片:download / insert)
# 文档正文媒体
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> ⚠️ **图片插入硬规则**:
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws drive download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
> - **禁止**把钉盘/文档节点 URL(如 `alidocs.dingtalk.com/i/nodes/...`)写进 Markdown `![](...)` 图片语法——这些是页面链接,不是可渲染的图片资源
> - 创建文档时需要图文并茂:先用 `doc create` 写入纯文本骨架,再对每张图片执行 `media insert`,最后用 `doc block list` 验证附件块存在
---
## doc media insert(上传附件并插入文档)
`media insert` 是三步自动完成的流程(获取附件上传凭证 → OSS 上传 → 插入附件块到文档),无需手动分步操作。
> **图片插入也走 `media insert`**:用户说"插图 / 加图片 / 嵌入图片"时使用本命令,不要走 [`doc-block.md`](./doc-block.md) `block insert`。
```
Usage:
dws doc media insert [flags]
Example:
dws doc media insert --node <DOC_ID> --file ./report.pdf
dws doc media insert --node <DOC_ID> --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
dws doc media insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where before
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--file string 本地文件路径 (必填)
--name string 附件显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
--index int 插入位置索引
--where string 相对位置: before / after (配合 --ref-block)
--ref-block string 参考块 ID (配合 --where)
```
### 关键说明
- `--mime-type` 可选,不指定时根据扩展名自动推断;支持常见文件类型(PDF、Office、图片、视频、压缩包等)。
- 与 `dws drive upload` 的区别:`drive upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
---
## doc media download(下载文档附件)
```
Usage:
dws doc media download [flags]
Example:
dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/xxx" --resource-id <RESOURCE_ID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--resource-id string 附件资源 ID,可通过 dws doc block list 获取 (必填)
```
### 关键说明
- `media download` 用于获取文档正文中附件的**临时下载链接**。
- `--resource-id` 可通过 [`./doc-block.md`](./doc-block.md) `block list` 返回的 attachment 块获取,或从 [`./doc-read.md`](./doc-read.md) 返回的 OSS 链接 `/att/<resourceId>.ext` 中提取。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `media insert` `resourceId` | 附件已插入文档;可通过 [`./doc-block.md`](./doc-block.md) `block list` 查看附件块 |
| `media download` `downloadUrl` | 下载文档中的附件资源(临时链接,会过期) |
## 常用模板
## Golden Route
```bash
# 基本用法:插入本地文件到文档
dws doc media insert --node <DOC_ID> --file ./report.pdf
# 列出图片和附件,取得真实 resourceId/blockId
dws doc +media-list --node <DOC_ID> --format json
# 指定附件显示名称
dws doc media insert --node <DOC_ID> --file ./data.xlsx --name "Q1数据报表.xlsx"
# 插入工作目录内的本地文件
dws doc +media-insert --node <DOC_ID> --file ./image.png --format json
# 指定 MIME 类型(扩展名无法推断时)
dws doc media insert --node <DOC_ID> --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream
# 下载到工作目录,默认不覆盖
dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/ --format json
# 在指定块之前插入附件
dws doc media insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where before
# 完整流程:创建文档 → 写入内容 → 插入附件
dws doc create --name "项目报告" --content "# 项目报告\n\n以下为相关附件:" --content-format markdown
# 提取 nodeId 后:
dws doc media insert --node <DOC_ID> --file ./design.pdf
dws doc media insert --node <DOC_ID> --file ./timeline.xlsx --name "项目时间线.xlsx"
# 下载文档中的附件(resourceId 从 block list 获取)
dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
# 只为临时查看下载到受控临时目录
dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID> --format json
```
## 参考
## 稳定 ID 与结果
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(block list 取 attachment 的 resourceId)
- [`../drive.md`](../../../dingtalk-drive/references/drive.md)(独立文件上传:`dws drive upload`)
- [`./style/doc-style-guideline.md` §4.9 附件与图片](./style/doc-style-guideline.md)(图示与附件使用规范)
- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。
- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。
- 下载输出只接受工作目录内相对路径,默认 no-clobber。
- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。
## 失败最短路径
- `download_doc_attachment` 失败:保留 `nodeId/resourceId` 和服务端错误,停止。
- 临时链接过期:重新调用 `+media-download` 获取新链接,由 CLI 内部下载。
- 禁止把 `+fetch` 返回的临时 OSS URL 交给 `curl/wget`。
- 禁止安装图片、Office 或 Python 依赖作为隐式降级。
- 不得把纯数字 dentryId 当作 drive folder 重试;需要文件树定位时切换到 `dingtalk-drive` 并使用真实 dentryUuid。
只有 shortcut 缺少必要的底层定位参数时,才读取精确原子 leaf Schema;不要把 `doc media insert/download` 作为默认入口。
@@ -1,121 +1,33 @@
# doc read(读取文档内容)
# 读取文档:`+fetch` Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
>
> **同任务常配合**:[`doc-info.md`](doc-info.md)(先解析 URL,确认 extension=adoc)/ [`doc-update.md`](doc-update.md)(读后改写)/ [`doc-block.md`](doc-block.md)(块级精修前先读结构)
## 命令格式
```
Usage:
dws doc read [flags]
Example:
dws doc read --node <DOC_ID>
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node <DOC_ID> --content-format jsonml --output ./doc.json
dws doc read --node <DOC_ID> --content-format jsonml --scope outline
Flags:
--node string 文档 ID 或 URL (必填)
--content-format string 输出格式: 默认为 markdown,可选 jsonml(返回完整 JSONML 结构)
--output string 输出到本地文件路径(仅 --content-format jsonml 时生效)
--scope string 按 scope 筛选节点(需 --content-format jsonml): outline(全部 h1-h6 标题)/range(区间)/section(单块)/tags(配合 --tags 自定义); 详见下方 §scope/tags 节点筛选
--tags string 自定义 JSONML tag 列表(逗号分隔, 如 h1,h2,table); 仅在 --scope tags 时使用且必填
--max-depth int 筛选遍历最大深度, 0 表示不限(仅 --content-format jsonml + --scope/--tags 时生效)
--start-block-id string range/section 起始块 ID(节点 uuid); scope=range/section 时必填
--end-block-id string range 结束块 ID(节点 uuid); "-1"或空=到文档末尾(仅 scope=range 生效)
```
## 关键说明
- 默认返回 **Markdown** 格式的文档内容,仅限有"下载"权限的文档。
- 返回的 Markdown 中,附件以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/<resourceId>.ext?Expires=...`),**链接会过期**。链接过期后从 URL 路径中提取 `<resourceId>`(即 `/att/` 后、扩展名前的 UUID 部分),用 `media download --node <DOC_ID> --resource-id <resourceId>` 重新获取下载链接。
- `--content-format jsonml` 返回完整 JSONML 结构(含 `revision`),用于无损读改写;可直接配合 [`doc-update.md`](./doc-update.md) 的 `--content-format jsonml --content-file` 写回。`revision` 仅在并发敏感场景下需要透传给 update 触发并发检查(详见下方)。
## content-format=jsonml 输出
输出 JSON 对象,包含 `revision`(版本号)和 `jsonml`(JSONML body 数组):
```json
{
"revision": 42,
"jsonml": ["root", {"sectPr": {}}, ["p", {"uuid": "abc"}, "Hello"], ...]
}
```
可直接用于 `doc update --content-format jsonml --content-file` 写回。`revision` 字段在普通改写场景下**不需要**透传——`doc update` 默认直接覆盖。仅在担心多 agent 并发覆盖时,才把 `revision` 通过 `--revision` 透传给 update 触发并发检查(详见下方 §并发安全模式)。
## scope/tags 节点筛选(返回 JSONML fragment)
当只想读取文档的**部分节点**(如大纲、某区间、某个块)时,用 `--scope` 筛选,避免拉取整篇。**筛选是 JSONML 链路能力,MUST 配合 `--content-format jsonml`**(缺失会报错 `--scope/--tags requires --content-format jsonml`)。**`--scope` 必须显式指定为 `outline`|`range`|`section`|`tags` 之一**;`--tags` 仅在 `--scope tags` 时使用(其他组合会报错):
- **tag 类**(outline / tags):按节点类型筛选。`outline`=全部 h1-h6 标题预设;`--scope tags --tags h1,table` 按自定义 tag 子集筛选(tables/images/code 预设已下线,改用 `--scope tags --tags table`/`img`/`code`)。
- **锚点类**(range/section):按位置截取。`range`=顶层 `[start,end]` 闭区间(`--end-block-id "-1"` 或留空=到文末);`section`=只取 `--start-block-id` 指向的单块(完整子树)。块 ID(节点 uuid)可从 [`doc-block.md`](./doc-block.md) 的 `list_document_blocks` 或 JSONML 节点的 `uuid` 属性获得;`scope=range/section` 时 `--start-block-id` 必填。
### 返回:JSONML fragment 容器(MUST 剥壳消费)
筛选结果是一个 **JSONML fragment 容器**(承载在 `jsonml` 字段):
```json
["fragment", {"source": "outline"}, ["h1", {"uuid": "h1a"}, "一级标题"], ["h2", {"uuid": "h2a"}, "二级标题"]]
```
- `source` 标识产生结果的操作(range/section/outline/tags)。
- fragment 是**查询结果虚拟容器**,仅用于读出:
- **消费时 MUST 剥掉 fragment 外层**(跳过 index 0 的 `"fragment"` 与 index 1 的 attrs 对象),取其后的 children 使用。
- **MUST NOT 把 fragment 整体写回文档**(如透传给 `doc update --content-format jsonml`)——服务端 JSONML 写校验会拦截。若要写回,只写剥壳后的 children。
## 唯一推荐入口
```bash
# 读文档大纲(目录)
dws doc read --node <DOC_ID> --content-format jsonml --scope outline
# 读某区间(从块 A 到块 B)的 JSONML
dws doc read --node <DOC_ID> --scope range --start-block-id <UUID_A> --end-block-id <UUID_B> --content-format jsonml
# 读单个块及其子树
dws doc read --node <DOC_ID> --content-format jsonml --scope section --start-block-id <UUID>
# 按自定义 tag 读取(如所有表格 / 图片)
dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags table,img
dws doc +fetch --node <DOC_ID_OR_URL> --format json
dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --format json
```
## 上下文传递
- 已知 ID 或 URL:传 `--node`。
- 只知道标题:传 `--query`;跨页解析必须唯一命中,否则停止并要求用户选择。
- `--node` 与 `--query` 必须且只能提供一个。
- 默认 `--detail simple --scope full`,适合普通阅读,避免加载不必要的 JSONML。
| 从返回中提取 | 用于 |
|-------------|------|
| Markdown 正文 | 用户可读输出 / 二次处理 |
| JSONML `jsonml`(完整 body,无 scope/tags 时) | [`doc-update.md`](./doc-update.md) 的 `--content-file` + `--content-format jsonml`(可整体写回) |
| JSONML `jsonml`(fragment,scope/tags 筛选时) | 剥掉 fragment 外层取 children 使用;**MUST NOT 整体写回** |
| JSONML `revision` | [`doc-update.md`](./doc-update.md) 的 `--revision`(可选;担心被并发覆盖时使用) |
| 附件链接中的 `resourceId` | [`doc-media.md`](./doc-media.md) 的 `--resource-id`(链接过期后续期) |
## 常用模板
## 局部读取
```bash
# 默认 Markdown 输出(最常用)
dws doc read --node <DOC_ID> --format json
# alidocs URL 直传
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# JSONML 完整结构 → 文件(无损改写前置)
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json
# 之后修改 /tmp/doc.json 中的 jsonml 数组,再用:
# dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite
# 担心被并发覆盖时,再加 --revision <从上面 read 拿到的 revision>
dws doc +fetch --node <DOC_ID> --scope outline --detail with-ids --format json
dws doc +fetch --node <DOC_ID> --scope section --start-block-id <BLOCK_ID> --detail full --format json
dws doc +fetch --node <DOC_ID> --scope range --start-block-id <A> --end-block-id <B> --detail full --format json
dws doc +fetch --node <DOC_ID> --scope tags --tags table,img --detail full --format json
dws doc +fetch --node <DOC_ID> --scope keyword --keyword "风险|结论" --context-before 120 --context-after 240 --format json
```
## 并发安全模式(担心被并发覆盖时使用)
只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。
如果你担心在编辑期间别人也在改这个文档,可以把 read 返回的 `revision` 透传给 update 触发服务端并发检查:
## 后续路由
1. `dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json` — 输出 JSON 中的 `revision` 字段(如 `42`)记下来。
2. 编辑 `/tmp/doc.json` 中的 `jsonml` 字段。
3. `dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 文档若在期间被改过,服务端返回 `VersionConflict`,重做第 1 步即可。
- 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。
- 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。
- 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。
不带 `--revision` 时服务端不做并发检查,直接覆盖;普通单 agent 编辑场景下默认不传即可。
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType / extension)
- [`./doc-update.md`](./doc-update.md)(读后改写)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构)
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。
@@ -1,236 +1,50 @@
# doc update(更新文档内容)
# 更新在线文字文档
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、分片 append、回读验收)
> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范
> 4. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
>
> **同任务常配合**:[`doc-read.md`](./doc-read.md)(改写前必读,jsonml 模式拿当前结构;担心被并发覆盖时再取 revision)/ [`doc-block.md`](./doc-block.md)(单 block 改写优先;本命令更适合追加 / 整篇 overwrite)
## 唯一推荐入口
## 命令格式
```
Usage:
dws doc update [flags]
Example:
dws doc update --node <DOC_ID> --content "# 追加内容" --mode append
dws doc update --node <DOC_ID> --content "# 完整替换" --mode overwrite
dws doc update --node <DOC_ID> --content-file ./part1.md --mode append
dws doc update --node <DOC_ID> --content "# 插入到第3个block前" --mode append --index 2
dws doc update --node <DOC_ID> --content-file ./body.json --content-format jsonml --mode overwrite
cat part2.md | dws doc update --node <DOC_ID> --content - --mode append
Flags:
--node string 文档 ID 或 URL (必填)
--content string 文档内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--mode string 更新模式: overwrite=覆盖, append=追加 (必填)
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--revision int 文档版本号(仅 --content-format jsonml 时生效,可选);传入后服务端做并发检查,版本不一致时返回 VersionConflict。不传则直接覆盖,不做并发检查
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1
```
## 关键说明
- `--mode` 必填,无默认值。`overwrite` **清空原内容后重写**,谨慎使用;`append` 更安全。
- 整篇 overwrite 大文档前**必须**先向用户提示风险并等待确认(详见 [`./style/doc-update-workflow.md` §4.5](./style/doc-update-workflow.md))。
- `--content` 中的换行必须是**真实换行符**(Unicode `U+000A`),不是字面量 `\n`;多行/表格/长文本优先 `--content-file` 或 `--content -`。
- **写入后必须回读**——返回 `success=true` 不等于内容真的写入完整(详见 [`./style/doc-update-workflow.md` §6](./style/doc-update-workflow.md))。
## JSONML 格式写入
使用 `--content-format jsonml` 可以 JSONML 结构直接写入文档,实现无损读写。当前仅支持 `--mode overwrite`。
**输入格式**:JSON 对象,包含 `jsonml` 字段(文本必须用 `span/data-type=text + span/data-type=leaf` 包裹,详见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)):
```json
{"jsonml": ["root", {"sectPr": {}},
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]],
["p", {"uuid": "p2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "world"]]]
]}
```
> CLI 不做结构修复——裸字符串、缺 uuid 等错误会被 validator 直接抦下。body 必须以 `["root", ...]` 为根节点,缺少会报错。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用 JSON 语法修复。
**典型流程**(无损读改写):
1. `dws doc read --node <DOC_ID> --content-format jsonml --output ./doc.json` — 获取文档 JSONML 结构
2. 修改 `doc.json` 中的 jsonml 数组内容
3. `dws doc update --node <DOC_ID> --content-file ./doc.json --content-format jsonml --mode overwrite` — 写回
> 默认不传 `--revision`,服务端直接覆盖,不做并发检查。担心多 agent 同时改时,按下方 §并发安全模式 加 `--revision`。
**节点结构参考**:[`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)
### 并发安全模式(担心被并发覆盖时使用)
如果你担心在编辑期间别人也在改这个文档,可以传 `--revision` 触发服务端并发检查:
1. `dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json` — 返回的 JSON 里有一个 `revision` 字段(比如 `42`)。
2. 编辑 `/tmp/doc.json` 里的 `jsonml` 字段。
3. `dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 如果文档在期间被改过,服务端返回 `VersionConflict`,此时重新执行第 1 步即可。
不带 `--revision` 时,服务端不做并发检查,直接覆盖。普通单 agent 编辑场景下默认不传即可。
## 内容写入管道(create / update 共用)
> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。
### 输入方式选择
| 场景 | 推荐方式 | 说明 |
|------|---------|------|
| 短文本(<2KB,无换行/表格/特殊字符) | `--content "..."` | 字面量传入,最简单 |
| 长文本(≥2KB)、含换行、含表格 | `--content-file ./file.md` | **必须**用文件路径,避免 shell escape 和截断 |
| 含特殊字符(`"`、`\`、`$`、`` ` ``) | `--content-file ./file.md` | 字面量传入会被 shell 转义破坏 |
| 管道/heredoc 输入 | `--content -` 或 `cat file \| dws doc ...` | 从 stdin 读取 |
### 自动分片行为
当内容超过 30000 字符时,CLI 自动执行:
1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append
2. **update (overwrite)**: 第一片用 overwrite,后续片用 append
3. **update (append)**: 所有片段用 append
分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性)
如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。
### 输出格式
写入成功后输出 JSON(混合 `[INFO]` 进度行):
```json
{"success": true, "nodeId": "xxx", "chunksWritten": 3}
```
| 字段 | 说明 |
|------|------|
| `nodeId` | 文档节点 ID,可用于后续读取或追加 |
| `chunksWritten` | 实际写入的分片数(1 = 单次写入) |
### 内容完整性验证(必读)
CLI **不会**自动执行回读验证。**你必须在文档写入完成后主动回读确认**:
1. 使用 `dws doc read --node <nodeId>` 读取写入后的文档内容
2. 检查关键段落是否完整、顺序是否正确
3. 如发现内容缺失或异常,使用 `dws doc update --mode append` 补写缺失部分
> **何时回读**:每次 create/update 操作完成后都应回读。如果是连续多次编辑同一文档,可以在全部编辑完成后统一回读一次。
### 进度输出示例
```
[INFO] 内容较长 (45000 字符),自动分片写入...
[INFO] 已创建空文档 (nodeId=abc123),开始分片写入...
[INFO] 写入分片 (1/3),15000 字符...
[INFO] 写入分片 (2/3),15000 字符...
[INFO] 写入分片 (3/3),15000 字符...
[INFO] 全部 3 个分片写入完成
{"success": true, "nodeId": "abc123", "chunksWritten": 3}
```
### CONTENT_TRUNCATED 错误
当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略:
1. 检查网络和后端服务状态
2. 已写入的部分内容可通过 `dws doc read --node <NODE_ID>` 查看
3. 从断点处手动用 `dws doc update --mode append` 继续追加
## 长 Markdown 写入
**核心规则**:含多行、表格、`\n` 或长度 >2KB 的 Markdown **必须**通过 `--content-file` 或 `--content -`(stdin)传入,禁止直接作为 `--content` 命令行字符串——shell escape 会破坏换行和表格,且命令行长度受限。
`dws doc create` 和 `dws doc update` 支持两种内容来源(`--content-file` 优先于 `--content`):
| 形式 | 说明 |
|------|------|
| `--content "..."` | 字面量(仅推荐短文本 <2KB 且无换行/表格) |
| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) |
| `--content-file path` | 从文件读取(UTF-8),推荐 |
### 短/中等长度(< 200KB)— 单步写入
普通追加、覆盖和 block 编辑统一使用 `+update`:
```bash
# 1. 把内容写入 UTF-8 文本文件:
# Linux/Mac: /tmp/<name>.md;Windows: %TEMP%\<name>.md
# 2. 一步写入:
dws doc update --node <DOC_ID> --content-file <tmp> --mode overwrite --content-format markdown
dws doc +update --node <DOC_ID> --command append --content "补充说明" --format json
dws doc +update --node <DOC_ID> --command append --content @append.md --format json
dws doc +update --node <DOC_ID> --command overwrite --content @full.md --format json
dws doc +update --node <DOC_ID> --command overwrite --doc-format jsonml --content @full.json --expected-revision <REVISION> --format json
dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容" --format json
```
### 超长(> 200KB 兜底)— 分片追加
重要覆盖或明确要求恢复点时使用:
```bash
# 1. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格)
# 2. 逐个追加:
dws doc update --node <nodeId> --content-file <part> --mode append --content-format markdown
dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @full.md --format json
```
> **注意**:分块 append 存在静默失败风险(部分片段返回 success 但实际未写入),执行前**必须**向用户发出截断风险提示并等待确认。完整规范见 [`../../best_practices/04-document.md` «分块 append 截断风险提示»](../04-document.md)。
`+checkpoint-update` 负责保存版本、写入和回读,不要手工编排 `version save → update → read`。
### stdin 变体
## 动作与输入
```bash
# pipe
cat report.md | dws doc update --node <DOC_ID> --content - --mode append --content-format markdown
| `--command` | 用途 | 必要参数 |
|---|---|---|
| `append` | 末尾追加 | `--content` |
| `overwrite` | 整篇覆盖 | `--content`;JSONML 可加 `--expected-revision` 做服务端原子条件写 |
| `block_insert_after` | 在指定 block 后插入 | `--after-block-id --content` |
| `block_replace` | 替换指定 block | `--block-id --content` |
| `block_delete` | 删除指定 block | `--block-id` |
| `str_replace` | 唯一普通文本替换 | `--old --new` |
| `block_copy_insert_after` | 复制 block 后插入 | `--block-id --after-block-id` |
# heredoc(真实换行,含表格)
dws doc update --node <DOC_ID> --mode append --content - --content-format markdown <<'EOF'
## 追加段落
统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。禁止绝对路径、`..`,也不要猜测 `--content-file`、`--content-format` 或 `replace_all`。
| 列1 | 列2 |
|---|---|
| a | b |
EOF
```
block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止编造。
## 上下文传递
`--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。
| 从返回中提取 | 用于 |
|-------------|------|
| `success` + `chunksWritten` | 判断是否需要回读补救(`chunksWritten > 1` 时重点查章节顺序) |
| 错误码 `CONTENT_TRUNCATED` | 触发 [`./doc-read.md`](./doc-read.md) 查断点 + 再次 `update --mode append` |
## 确认与验证
## 常用模板
- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。
- `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。
- `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。
- `partial_success` 只恢复未完成步骤,不能重放成功步骤。
```bash
# overwrite 整段(用户已确认)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.md --mode overwrite --content-format markdown
## 高级通道
# append 末尾追加
dws doc update --node <DOC_ID> --content-file /tmp/<name>-append.md --mode append --content-format markdown
# append 到指定 block 前(index 通过 doc block list 获取)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.md --mode append --index 2 --content-format markdown
# JSONML 整篇无损 overwrite(默认不做并发检查;并发敏感时加 --revision <N>)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.json --content-format jsonml --mode overwrite
# 短文本字面量(<2KB 无换行)
dws doc update --node <DOC_ID> --content "## 简短追加" --mode append --content-format markdown
# stdin(pipe)
cat report.md | dws doc update --node <DOC_ID> --content - --mode append --content-format markdown
# stdin(heredoc,含表格)
dws doc update --node <DOC_ID> --mode append --content - --content-format markdown <<'EOF'
## 追加段落
| 列1 | 列2 |
|---|---|
| a | b |
EOF
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-read.md`](./doc-read.md)(改写前必读;jsonml 模式拿当前结构,担心并发时再取 revision)
- [`./doc-block.md`](./doc-block.md)(单 block 改写更精准)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、分片 append 风险、回读验收)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(排版规范)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 范例 / 节点结构)
只有 shortcut 未公开底层参数或必须保留原始响应时,才读取精确 leaf Schema 后使用 `dws doc update` / `doc block`。富结构保真按需读取 [doc-update-workflow.md](style/doc-update-workflow.md),但执行入口仍优先使用 `+update/+checkpoint-update`。
@@ -1,13 +1,13 @@
# JSONML Cookbook
> 本文档提供 `dws doc create/update --content-format jsonml` 和 `dws doc block insert/update --content-format jsonml` 的**完整可用范例**。
> 本文档提供 JSONML **结构范例**。正常执行入口是 `+create --content @./相对文件 --doc-format jsonml` 与 `+update --content @./相对文件 --doc-format jsonml`;原子 `doc create/update/block` 仅用于 shortcut 未公开字段的专家路径,使用前读取精确 leaf Schema。
> 所有示例均基于真实文档 serialize 输出验证。节点结构详细定义见 [doc-jsonml-schema.md](./doc-jsonml-schema.md)。
> 合法节点类型和属性的权威参考为 `wukong/products/jsonml-schema-v2.json`。
## 决策型文档骨架范例(doc create 用)
以下是一个"方案对比汇报"的完整 JSONML 文件内容,展示摘要 callout + 彩色表格 + 状态高亮。
**可直接复制到 `/tmp/<name>.json` 后用 `dws doc create --name "..." --content-file /tmp/<name>.json --content-format jsonml` 创建。**
**可保存到工作目录内的 `./drafts/<name>.json`,再用 `dws doc +create --name "..." --content @./drafts/<name>.json --doc-format jsonml` 创建。**
```json
["root", {},
@@ -1,6 +1,8 @@
# 钉钉文档创建流程
本文只处理一件事:用 `dws doc create` 创建一篇钉钉文档,并确认内容真的写进去。资料采集、汇报生成、转发通知、权限分发等都不是本文范围,应由对应 recipe 或产品参考负责。
本文只处理文档结构和排版设计。普通创建的执行入口固定为 `dws doc +create`,命令内置分片与回读验证;原子 `doc create/read/update` 仅用于 shortcut 未公开所需参数的专家路径,使用前必须读取精确 leaf Schema。
> **路由硬约束:** 正文文件使用 `--content @./相对路径 --doc-format markdown|jsonml`;禁止 `/tmp`、绝对路径、已删除的 Python 脚本以及“创建后再额外 read”固定流水。
> 改写已有文档见 [doc-update-workflow.md](./doc-update-workflow.md)。排版规范见 [doc-style-guideline.md](./doc-style-guideline.md)。
@@ -28,8 +30,7 @@
- 文档标题和创建位置确认
- 正文草稿准备
- `doc create` 写入
- 写入后回读验收
- `+create` 写入并自动回读验收
- 内容缺失时的补救写入
本文不覆盖:
@@ -220,8 +221,8 @@ callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸
| 策略 | 条件 | 执行流程 |
|------|------|----------|
| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `/tmp/<name>.json` 手写完整 JSONML 树 → `doc create --content-format jsonml` |
| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `doc create --content-format markdown` ② `doc read --node <id> --content-format jsonml --output /tmp/<name>.json` 拉回 JSONML(已含 uuid) ③ **Read `<name>-rfc.md` + `<name>-design.md`** 回顾规划 ④ 逐章对照执行结构变换 + 叠加样式 |
| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `./drafts/<name>.json` 编写完整 JSONML 树 → `+create --content @./drafts/<name>.json --doc-format jsonml` |
| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `+create` ② 需要精修时用 `+fetch --detail full` 拉回 JSONML ③ 逐章执行结构变换与样式叠加 |
| **直接 JSONML 分段构造** | 长文档且含大量富结构 | 按章节分段构造 JSONML,每段写完校验通过后再写下一段,最后拼接 |
> **脚手架策略警示**:Markdown 无法表达分栏/callout/色彩表头,拉回的 JSONML 只有纯文本骨架。精修阶段不是“在现有结构上加色”,而是“参照 RFC/Spec 重组结构”。
@@ -295,20 +296,18 @@ callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸
### 写入
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml
dws doc +create --name "<文档名>" --content @./drafts/<name>.json --doc-format jsonml
```
### 回读验收
### 验收
```bash
dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readback.json
```
读取 `+create` 返回的 `verified`、`steps`、`nodeId` 和验证结果。只有返回 `verified=false` 或结构化 partial/unknown 错误时,才进入恢复流程。
---
## 正文准备(未命中 JSONML 判定时)
正文草稿先在本地临时 Markdown 文件中完成,推荐路径形如 `/tmp/<name>.md`。
正文草稿先在工作目录内的 Markdown 文件中完成,推荐路径形如 `./drafts/<name>.md`。
准备规则:
@@ -320,32 +319,32 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
- Markdown 草稿阶段**不要**写 callout / 分栏 / 附件——这些留到「创建后的精修」用 `doc block insert` 操作(style-guideline §1.3)。
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `![](...)` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws drive download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,Markdown 只写文本骨架和图片占位说明;创建后用 `dws doc +media-insert --node <nodeId> --file ./相对路径` 插入,再用 `+media-list` 验证稳定 `resourceId`。禁止正文临时 URL、绝对路径、curl/wget 和本地依赖安装兜底。
## 创建写入
优先用 `--content-file` 一次创建并写入:
优先用工作目录相对文件一次创建并写入:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --content-format markdown
dws doc +create --name "<文档名>" --content @./drafts/<name>.md --doc-format markdown
```
创建到指定文件夹:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --folder <DOC_FOLDER_NODE_ID> --content-format markdown
dws doc +create --name "<文档名>" --content @./drafts/<name>.md --folder <DOC_FOLDER_NODE_ID> --doc-format markdown
```
创建到知识库:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --workspace <WS_ID> --content-format markdown
dws doc +create --name "<文档名>" --content @./drafts/<name>.md --workspace <WS_ID> --doc-format markdown
```
短纯文本才允许直接传 `--content`:
```bash
dws doc create --name "<文档名>" --content "短内容" --content-format markdown
dws doc +create --name "<文档名>" --content "短内容" --doc-format markdown
```
返回后立即记录:
@@ -356,13 +355,9 @@ dws doc create --name "<文档名>" --content "短内容" --content-format markd
| `docUrl` | 最终交付给用户的链接;缺失时用 `doc info` 补查 |
| `chunksWritten` | 判断是否触发自动分片;大于 1 时重点检查章节顺序 |
## 回读验收
## 内置回读验收
创建命令返回成功不等于正文完整。每次创建后都必须回读:
```bash
dws doc read --node <nodeId>
```
`+create` 已在同一执行内回读验证,禁止再固定追加一次 `doc read`。检查结构化返回:
验收要点:
@@ -382,15 +377,15 @@ DWS 写入管道会自动处理长内容分片。只有出现以下情况才手
补救流程:
1. 用 `doc read` 确认已经写到哪个章节。
2. 从原始临时 Markdown 中截取缺失部分,写入 `/tmp/<name>-resume.md`。
1. 用 `+fetch` 的最小 scope 确认已经写到哪个章节。
2. 从原始 Markdown 中截取缺失部分,写入 `./drafts/<name>-resume.md`。
3. 追加缺失内容:
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-resume.md --mode append --content-format markdown
dws doc +update --node <nodeId> --command append --content @./drafts/<name>-resume.md --doc-format markdown
```
4. 再次 `doc read`,确认缺失章节已补齐。
4. 使用 `+update` 返回的验证结果确认缺失章节已补齐;结果未知时再定点 `+fetch`。
## 创建后的精修
@@ -400,7 +395,7 @@ dws doc update --node <nodeId> --content-file /tmp/<name>-resume.md --mode appen
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认执行 schema validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
- 整篇 JSONML 无损:`doc update --content-format jsonml --mode overwrite`(默认直接覆盖,适合一次改多处或改 root sectPr;担心并发覆盖时加 `--revision <N>` 触发并发检查)
- 插入附件 / 图片:`doc media insert`(无 JSONML 形态,直接走 element)
- 插入附件 / 图片:`+media-insert`,之后用 `+media-list` 验证稳定 `resourceId`
- element JSON 次选:`doc block insert` / `doc block update` 不带 `--content-format jsonml` 时按老接口 JSON 解析;仅在 JSONML 不支持某字段时使用
- markdown 兜底:`doc update --mode append`(末尾追加纯文本段落,无富结构需保留时)
@@ -2,7 +2,7 @@
本文规定 DWS 创建或编辑钉钉文档时的排版判断方法。核心流程:**确定文档类型 → 选骨架 → 按读者任务选元素 → 按视觉语义统一表达 → 软约束自检**。
> 写入流程见 [doc-create-workflow.md](./doc-create-workflow.md)。改写老文档见 [doc-update-workflow.md](./doc-update-workflow.md)。callout / 分栏等块的字段以 [doc.md](../../doc.md) 与 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 为准。
> 本文只定义内容结构和视觉规范,不定义命令路由。写入统一使用 `+create/+update/+checkpoint-update`,读取使用 `+fetch`,媒体使用 `+media-*`;原子命令仅用于精确 Schema 支持的专家路径。流程见 [doc-create-workflow.md](./doc-create-workflow.md) 和 [doc-update-workflow.md](./doc-update-workflow.md)。
## 快速入口
@@ -30,7 +30,7 @@
3. **Markdown 草稿阶段只用稳定元素**:标题、段落、列表、checklist、表格、代码块;callout / 分栏 / 附件 / 复杂嵌套留到创建后用 `doc block insert` / `doc media insert` 精修
4. **引用块只用于原文**:用户原话、会议摘录、外部材料原文;不许包装作者自己的结论
5. **不编造 URL**:图片、链接、文档 ID 不确定时留 TODO 占位,向用户求证
6. **写入后必须回读**:见 [doc-create-workflow.md «回读验收»](./doc-create-workflow.md) 与 [doc-update-workflow.md §6](./doc-update-workflow.md)
6. **以 shortcut 内置验证为准**:正常成功不追加整篇回读;partial/unknown 或富结构定点检查才使用最小范围 `+fetch`
---
@@ -236,7 +236,7 @@
~~~~markdown
```bash
dws doc read --node abc123
dws doc +fetch --node abc123
```
~~~~
@@ -307,13 +307,13 @@ dws doc block insert --node <nodeId> --content-format element \
### 4.9 附件与图片
```bash
dws doc media insert --node <nodeId> --file ./diagram.png
dws doc +media-insert --node <nodeId> --file ./diagram.png
```
- 插入后在前后用一句话说明它支持哪个结论
- 插入后用 `doc block list` 验证存在
- **禁止**在 Markdown 里编造无法访问的图片 URL
- **禁止**把 `alidocs.dingtalk.com/i/nodes/...`、`alidocs.dingtalk.com/i/document/...` 或任何文档/节点页面 URL 当作图片 src——这些是页面链接,不是图片资源,写入后无法渲染。图片必须先下载到本地,再通过 `dws doc media insert --node <docId> --file <本地路径>` 插入
- **禁止**把 `alidocs.dingtalk.com/i/nodes/...`、`alidocs.dingtalk.com/i/document/...` 或任何文档/节点页面 URL 当作图片 src。图片必须位于工作目录内,再通过 `dws doc +media-insert --node <docId> --file ./相对路径` 插入
- 何时主动建议用户提供图见 §6
---
@@ -360,7 +360,7 @@ dws doc media insert --node <nodeId> --file ./diagram.png
1. 关键流程/架构/趋势能图示就图示,不用纯文本承载
2. **禁止**在 Markdown 里编造图片 URL(如 `![](https://example.com/diagram.png)`)
3. 正文里留占位(如 `📌 待补充:架构图`),向用户主动询问能否提供截图
4. 用户提供后用 `dws doc media insert --node <id> --file ./xxx.png` 插入,并在图前后补一句说明
4. 用户提供后用 `dws doc +media-insert --node <id> --file ./xxx.png` 插入,并在图前后补一句说明
---
@@ -1,6 +1,6 @@
# 钉钉文档改写流程
本文只处理一件事:用户给已有 nodeId 或 alidocs 链接,需要改写、润色、补充章节、转换段落形态时,按本文操作。从零创建新文档见 [doc-create-workflow.md](./doc-create-workflow.md)。
本文只处理已有文档的结构和排版策略。普通执行入口固定为 `+fetch`、`+update` 或 `+checkpoint-update`;这些 shortcut 统一确认、写入与验证。原子 `doc read/update/block` 只保留给 shortcut 未公开参数的 JSONML 专家路径,使用前必须读取精确 leaf Schema。
## 适用边界
@@ -64,7 +64,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
- **分块 append 内容截断**:超长文档分片写入时部分片段丢失或顺序错乱
- **编码/通道问题**:特殊终端下 UTF-8 内容传输乱码
因此 **每次 `doc update` 后必须回读校验**(见 §6)。整篇 overwrite 大文档前还要先向用户提示风险并等待确认。
因此写入必须通过带确认与验证的 shortcut。`+update/+checkpoint-update` 返回的验证结果是主证据;禁止无条件追加一次原子 `doc read`,只有 partial/unknown 或需要定点结构检查时才用 `+fetch`。
---
@@ -75,9 +75,9 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
| 用户需求 | 读取方式 | 定位方法 |
|----------|----------|----------|
| 单块精修(首选)| `doc block list --node <id> --content-format jsonml` → 拿 uuid → `doc block list --node <id> --content-format jsonml --block-id <uuid>` 读子树 | 节点结构见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) |
| 多处保真改写 / 改 root sectPr | `doc read --node <id> --content-format jsonml --output /tmp/doc.json` | 解析 JSON,按 schema 操作;担心并发覆盖时记下 `revision` 供 update 透传 |
| 整篇按新骨架重写(纯文本场景)| `doc read --node <id>`(markdown 输出)| 直接处理 markdown 全文,定位用 `grep -n "<章节关键词>"` |
| 末尾追加纯文本章节 | 不必读全文,直接 §4.2 append | 必要时 `doc read` 看末尾衔接 |
| 多处保真改写 / 改 root sectPr | `+fetch --node <id> --detail full` | 解析 JSONML;担心并发覆盖时记下 revision |
| 整篇按新骨架重写(纯文本场景)| `+fetch --node <id>` | 直接处理 markdown 全文 |
| 末尾追加纯文本章节 | 不必读全文,直接 §4.2 append | 必要时 `+fetch --scope section` 看末尾衔接 |
| 老接口快速找 BLOCK_ID(无需 jsonml 时)| `doc block list --node <id>` | 默认输出 JSON;用 `grep -B2 -A2 "<关键词>"` 在 children 里定位(结构 `{"blocks":[{...,"children":[...]}]}`,jq 需 `..\|.text? // empty` 递归查文本) |
读取后,把改写计划告诉用户(要改哪几节、走 JSONML 还是 markdown、改成什么形态),等用户确认后再写。
@@ -113,13 +113,13 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
> 适用范围:**纯文本**改写一段或替换某节内容。若该段含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构,**改走 §4.4 路径 B**——markdown 还原会丢失这些元素。
```bash
dws doc update --node <nodeId> --content "<新内容>" --mode overwrite --content-format markdown
dws doc +update --node <nodeId> --command overwrite --content "<新内容>" --doc-format markdown
```
或写入临时文件:
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-section.md --mode overwrite --content-format markdown
dws doc +update --node <nodeId> --command overwrite --content @./drafts/<name>-section.md --doc-format markdown
```
> ⚠️ **overwrite 须用户确认**——尤其是整篇文档 overwrite。
@@ -129,7 +129,7 @@ dws doc update --node <nodeId> --content-file /tmp/<name>-section.md --mode over
> 适用范围:在文档末尾加 X 章 / 补充纯文本段落。追加内容若含 callout / 分栏等富结构,先用本节 append 一个占位段落,再用 §4.4 路径 B 的 `block insert --content-format jsonml` 替换/精修。
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-append.md --mode append --content-format markdown
dws doc +update --node <nodeId> --command append --content @./drafts/<name>-append.md --doc-format markdown
```
按 [doc-style-guideline.md](./doc-style-guideline.md) 的元素选择规则准备追加内容。
@@ -187,16 +187,16 @@ dws doc block insert --node <nodeId> --ref-block <BLOCK_UUID> --where after --co
```bash
# 1. 读出完整 JSONML 结构(输出含 revision,普通改写场景下不需要)
dws doc read --node <nodeId> --content-format jsonml --output /tmp/doc.json
dws doc +fetch --node <nodeId> --detail full --format json
# 2. 解析 JSON,修改 jsonml 数组中的目标节点
# 节点结构见 doc-jsonml-schema.md,可复制范例见 doc-jsonml-cookbook.md
# 3. 写回临时文件 /tmp/doc_modified.json,格式 {"jsonml": [...]}
# 3. 写回工作目录内相对文件 ./drafts/doc_modified.json,格式 {"jsonml": [...]}
# 4. 提交修改(默认直接覆盖,不做并发检查)
dws doc update --node <nodeId> --content-file /tmp/doc_modified.json \
--content-format jsonml --mode overwrite
dws doc +update --node <nodeId> --command overwrite --content @./drafts/doc_modified.json \
--doc-format jsonml
```
> **并发安全模式(担心被并发覆盖时使用)**:如果担心多 agent 同时改这篇文档,可以把第 1 步 read 返回的 `revision` 通过 `--revision <N>` 透传给第 4 步:服务端会做并发检查,版本不一致返回 `VersionConflict`,此时回到第 1 步重读重写即可。普通单 agent 改写场景默认不传 `--revision`。
@@ -249,10 +249,10 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
得到确认后执行(markdown 兜底路径):
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-full.md --mode overwrite --content-format markdown
dws doc +checkpoint-update --node <nodeId> --mode overwrite --content @./drafts/<name>-full.md
```
**写入后必须回读**(§6)。如果发现旧内容残留,按 §6 的修复路径处理。
读取 `+checkpoint-update` 的 checkpoint、write、verify 步骤;只有 partial/unknown 时才按 §6 恢复。
### 4.6 超长内容追加(分块 append)
@@ -281,11 +281,7 @@ update 场景下的额外约束:
## 六、回读验收
**所有 `doc update` 完成后都必须回读**,无论 overwrite 还是 append:
```bash
dws doc read --node <nodeId> --content-format jsonml
```
`+update/+checkpoint-update` 已统一确认与验证。正常成功禁止额外整篇读取;partial/unknown 或确需检查富结构时,使用最小范围 `+fetch`。
校验要点:
@@ -16,4 +16,4 @@
| "搜一下智能化方案/最近 OKR 相关邮件/最近发版相关消息" | 搜企业知识内容 | `aisearch enterprise` | `doc search` / `mail search` / `chat message search` | 跨文档、消息、日程、听记、邮件等企业内容语义检索走 enterprise;具体 `queries/types/time-range` 抽槽见 `aisearch.md` |
| "我发给某人的消息/邮件/文档/今天我干了什么" | 搜行为记录 | `aisearch behavior` | `chat` / `mail` / `doc` / `report` | 关注“谁对什么做过什么”,走 behavior;具体 `behavior-type/direction/chat-scope` 抽槽见 `aisearch.md` |
| "把这段文字翻译成英文/translate this" | 通用文本翻译 | `chat text translate` | `doc` / `aisearch` | 纯文本翻译,不是文档编辑或语义搜索 |
| "帮我把这个文档翻译成日文" | 文档内容翻译 | 先 `doc get` 再 `chat text translate` | `chat text translate` 直接传文件 | translate 仅支持纯文本,需先提取文档内容 |
| "帮我把这个文档翻译成日文" | 文档内容翻译 | 先 `dws doc +fetch --node` 再 `chat text translate` | `chat text translate` 直接传文件 | translate 仅支持纯文本,需先提取文档内容;普通读取统一走 shortcut |
@@ -6,40 +6,27 @@
### query-doc
1. 用户已提供 URL / `nodeId` 时直接使用原值;否则全局搜索 `dws drive search --query "<关键词>" --format json` → `nodeId`(聚合钉盘+文档空间),或在指定空间执行 `dws wiki node search --workspace <WS_ID> --query "<关键词>" --format json` → `nodeId`。
2. 对选中的候选执行 `dws drive info --node <nodeId> --format json`,读取真实 `extension`。
3. 按类型分流:`adoc` → `dws doc read --node <nodeId> --format json`(大文档只抽章节);`md` → 切 `dingtalk-misc` 的 `references/markdown.md`,用 `dws markdown fetch` 读取原文;`axls` → 切 `dingtalk-misc` 的 `references/sheet.md`;`able` → 切 `dingtalk-aitable`;xlsx/xls/xlsm/csv 和其他普通文件 → 切 `dingtalk-drive`。非 `adoc` 禁止执行 `doc read`。
1. 用户已提供 URL / `nodeId` 时执行 `dws doc +fetch --node <目标> --format json`。
2. 只有标题时执行 `dws doc +fetch --query "<唯一标题>" --format json`,让 Runtime 解析候选与类型。
3. 零命中、多候选、分页不完整或非 `adoc` 时停止并按返回建议切换产品;禁止无界穷举搜索。
### list-folder-docs
`drive list --workspace <WS_ID>` 或 `wiki node list --workspace <WS_ID>`
`dws doc +list --workspace <WS_ID> --format json`;知识库层级管理切 `dingtalk-wiki`。
### import-file
将本地文件导入为钉钉在线文档。**一条命令完成上传+格式转换+创建**,无需先读取文件内容。
将本地文件导入为钉钉在线对象。**一条命令完成上传、转换和创建**,无需先读取文件内容。
```bash
dws doc import --file ./report.docx --format json
dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
```
1. 确认文件路径(用户提供的本地文件路径)
2. 执行:`dws doc import --file <文件路径> --format json`(可选 `--folder <文件夹ID>` / `--workspace <知识库ID>` / `--name "文档名"`)
3. 从返回中提取 `documentUrl`,告知用户导入完成并提供链接
4. 超时或中断时 CLI 返回 `taskId`,用 `dws doc import get --task-id <taskId> --format json` 手动查询
1. 确认文件是工作目录内的相对路径。
2. 执行 `+import`,可选 `--folder`、`--workspace` 和 `--name`。
3. 从返回中提取 `documentUrl`;若 `fallback=upload`、`converted=false`,明确说明结果是原文件对象。
4. `partial_success/unknown` 时按返回的 `taskId/steps` 恢复,不得重跑整个导入。
**`--folder` 参数传值规则**:
- 首选路径:用户提供 alidocs URL 时,直接将完整 URL 传入 `--folder`,无需先调 `drive info`
- 预检路径:若需确认 URL 指向的是文件夹,可先调 `dws drive info --node <URL>`:
- `nodeType == "folder"` → 使用 `nodeId` 或原始 URL 作为 `--folder` 值
- `nodeType` 不是 folder → 提示用户:该链接指向的不是文件夹
- 禁止:不得使用 `drive info` 返回的 `folderId` 字段作为 `--folder` 的值(`folderId` 是父文件夹 ID,非当前节点 ID)
格式与文档类型映射:
- `.docx` / `.doc` → 文字文档(DOC)
- `.xlsx` / `.xls` → 电子表格(SHEET)
- `.xmind` / `.mark` → 脑图(MIND)
- `.md` / `.txt` → 文字文档(DOC)
> **禁止先 Read 文件再 `doc create` + `doc update`**。`doc import` 是服务端格式转换,客户端无需解析文件内容。
> 详见 [./doc/doc-import.md](./doc/doc-import.md)。
`--folder` 首选用户提供的 alidocs URL 或真实 `nodeId`;不得使用 `drive info` 返回的父级 `folderId`。
> 禁止先 Read 文件再 `doc create` + `doc update`。详见 [./doc/doc-import.md](./doc/doc-import.md)。
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""
在指定目录创建文档并写入 Markdown 内容(一键完成)
用法:
python doc_create_and_write.py \
--name "项目周报" \
--content "# 本周总结\n\n## 完成事项\n- 任务A"
python doc_create_and_write.py \
--name "会议纪要" \
--content-file notes.md
python doc_create_and_write.py \
--name "知识库文档" --content "# 内容" --folder FOLDER_ID
python doc_create_and_write.py --name "test" --content "hello" --dry-run
"""
import sys
import json
import time
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run_dws_with_retry(
args: List[str],
dry_run: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
) -> Optional[Any]:
"""带重试机制的 dws 命令执行"""
last_error = None
for attempt in range(1, max_retries + 1):
result = run_dws(args, dry_run=dry_run)
if result is not None:
return result
if attempt < max_retries:
print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
time.sleep(retry_delay)
retry_delay *= 1.5 # 指数退避
return None
def main():
parser = argparse.ArgumentParser(
description='创建文档并写入内容'
)
parser.add_argument('--name', required=True, help='文档名称')
parser.add_argument('--content', default='', help='Markdown 内容')
parser.add_argument('--content-file', default='', help='内容文件')
parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL')
parser.add_argument('--workspace', default='', help='目标知识库 ID')
parser.add_argument(
'--mode', default='append', choices=['overwrite', 'append'],
help='写入模式: overwrite=覆盖, append=追加 (默认 append)',
)
parser.add_argument(
'--max-retries', type=int, default=3,
help='每块写入失败时的最大重试次数 (默认 3)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
content = args.content
if args.content_file:
p = Path(args.content_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
content = p.read_text(encoding='utf-8')
if not content:
print('错误:需要 --content 或 --content-file')
sys.exit(1)
chunk_size = 10000
create_args = ['doc', 'create', '--name', args.name, '--format', 'json']
if args.folder:
create_args.extend(['--folder', args.folder])
if args.workspace:
create_args.extend(['--workspace', args.workspace])
print(f'\n📝 创建文档: {args.name}')
create_data = run_dws(create_args, dry_run=args.dry_run)
node_id = None
if not args.dry_run:
if not create_data:
sys.exit(1)
node_id = (create_data.get('nodeId')
or create_data.get('dentryUuid')
or create_data.get('id', ''))
print(f" ✓ 文档已创建 (ID: {node_id})")
if len(content) <= chunk_size:
mode_label = '追加' if args.mode == 'append' else '覆盖'
print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...')
write_data = run_dws([
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', content,
'--mode', args.mode,
'--format', 'json',
], dry_run=args.dry_run)
if write_data:
print(f" ✓ 内容已写入 ({len(content)} 字符)")
else:
chunks = []
pos = 0
while pos < len(content):
end = min(pos + chunk_size, len(content))
if end < len(content):
newline_pos = content.rfind('\n', pos, end)
if newline_pos > pos:
end = newline_pos + 1
chunks.append(content[pos:end])
pos = end
total_chunks = len(chunks)
print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...')
success_chunks = 0
for idx, chunk in enumerate(chunks):
chunk_mode = args.mode if idx == 0 else 'append'
write_data = run_dws_with_retry(
[
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', chunk,
'--mode', chunk_mode,
'--format', 'json',
],
dry_run=args.dry_run,
max_retries=args.max_retries,
)
if write_data:
print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)")
success_chunks += 1
elif not args.dry_run:
# 写入失败,报告部分写入状态
print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)")
print(f"\n⚠️ 文档处于部分写入状态:")
print(f" - 文档 ID: {node_id}")
print(f" - 已写入: {success_chunks}/{total_chunks} 块")
print(f" - 失败位置: 第 {idx + 1} 块")
if args.mode == 'overwrite':
print(f" - 模式: 覆盖模式,文档可能包含不完整内容")
print(f" - 建议: 手动检查文档内容,或删除后重新创建")
else:
print(f" - 模式: 追加模式,已写入内容已保存")
print(f" - 建议: 可手动补充剩余内容,或重新运行脚本")
sys.exit(1)
print('\n✅ 完成!')
if __name__ == '__main__':
main()
+111
View File
@@ -283,6 +283,117 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocSkillPinsExactVersionRoutesAndHelpBudget(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-doc", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-doc", "references", "doc.md"),
filepath.Join(root, "skills", "mono", "references", "products", "doc.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
for _, required := range []string{
"dws doc +version-save --node",
"dws doc +version-list --node",
"dws doc +version-revert --node",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing exact version route %q", path, required)
}
}
if strings.Contains(text, "dws doc +history-") {
t.Errorf("%s recommends a history compatibility route", path)
}
}
rootSkill, err := os.ReadFile(paths[0])
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"Help 不参与选路", "unknown flag", "只查一次 shortcut 清单", "禁止试探后缀"} {
if !strings.Contains(string(rootSkill), required) {
t.Errorf("Doc root Skill missing Help budget rule %q", required)
}
}
}
func TestCrossPlatformCoverageDocSkillPinsTemplateSearchRequiredQuery(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
path := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "skills", "multi", "dingtalk-doc", "SKILL.md"))
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(content)
for _, route := range []string{
"dws doc +template-list [--source MY\\|PUBLIC]",
"dws doc +template-search --query <名称或关键词>",
"dws doc +create-from-template --template-id <唯一ID>",
} {
if !strings.Contains(text, route) {
t.Errorf("Doc Golden Route does not publish mutually exclusive template route %q", route)
}
}
if strings.Contains(text, "`dws doc +template-search` →") {
t.Fatal("Doc Golden Route still recommends template-search without --query")
}
for _, required := range []string{
"准备 Help 时,本轮仅查一次",
"--fields use_when,avoid_when,parameters,constraints,confirmation",
"禁用产品级/`--all`",
"Help 不参与选路",
"禁止靠失败探测门禁",
"已有或临时文件先暂存到 cwd",
} {
if !strings.Contains(text, required) {
t.Errorf("Doc root Skill missing bounded Schema/input protocol %q", required)
}
}
}
func TestCrossPlatformCoverageDocSkillReferenceLoadingBudget(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-doc", "SKILL.md"),
filepath.Join(root, "skills", "mono", "references", "products", "doc.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
for _, required := range []string{
"禁止读取 reference",
"最多读取一个",
"复杂 JSONML",
"append/overwrite",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing bounded Reference rule %q", path, required)
}
}
if strings.Contains(text, "只在任务命中时读取一个精确 reference") {
t.Errorf("%s restores task-match-driven Reference loading", path)
}
}
}
func hasAny(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {