Compare commits

..
76 changed files with 1121 additions and 1668 deletions
-68
View File
@@ -82,74 +82,6 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
}
}
func TestFlagErrorWithSuggestionsDocParameterFamilies(t *testing.T) {
for _, tc := range []struct {
name string
path []string
flag string
wantHint string
wantText string
}{
{"shortcut-create-file", []string{"doc", "+create"}, "content-file", "doc create", "--content-file"},
{"inspect-info", []string{"doc", "+inspect"}, "include-info", "移除 --include-info", "默认返回"},
{"inspect-versions", []string{"doc", "+inspect"}, "include-versions", "--include-history", "版本历史"},
{"inspect-generic", []string{"doc", "+inspect"}, "include", "具体 --include-*", "block list"},
} {
t.Run(tc.name, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
parent := root
for _, name := range tc.path {
child := &cobra.Command{Use: name}
parent.AddCommand(child)
parent = child
}
cmd := parent
err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag))
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("want structured validation, got %T: %v", err, err)
}
if typed.Reason != "doc_parameter_family_mismatch" || !strings.Contains(typed.Hint, tc.wantHint) || !strings.Contains(typed.Message, tc.wantText) {
t.Fatalf("error = reason %q hint %q message %q", typed.Reason, typed.Hint, typed.Message)
}
})
}
}
func TestDocParameterFamilyErrorsStopBeforeDispatch(t *testing.T) {
for _, tc := range []struct {
path []string
flag string
}{
{[]string{"doc", "+create"}, "content-file"},
{[]string{"doc", "+inspect"}, "include-info"},
{[]string{"doc", "+inspect"}, "include-versions"},
{[]string{"doc", "+inspect"}, "include"},
} {
t.Run(strings.Join(tc.path, "/")+"/"+tc.flag, func(t *testing.T) {
calls := 0
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetFlagErrorFunc(flagErrorWithSuggestions)
parent := root
for _, name := range tc.path {
child := &cobra.Command{Use: name}
parent.AddCommand(child)
parent = child
}
parent.RunE = func(*cobra.Command, []string) error { calls++; return nil }
root.SetArgs(append(tc.path, "--"+tc.flag))
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "doc_parameter_family_mismatch" {
t.Fatalf("error = %#v", err)
}
if calls != 0 {
t.Fatalf("dispatch calls = %d, want 0", calls)
}
})
}
}
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
-58
View File
@@ -621,61 +621,6 @@ func enrichChatWorkbookError(cmd *cobra.Command, err error) error {
)
}
// enrichDocFlagError turns common native/shortcut parameter-family mixups into
// actionable local errors. It only uses the current Cobra command path and
// parse error, so it always runs before any helper RunE or MCP dispatch.
func enrichDocFlagError(cmd *cobra.Command, err error) error {
if cmd == nil || err == nil {
return err
}
path := cmd.CommandPath()
if fields := strings.Fields(path); len(fields) > 1 {
path = strings.Join(fields[1:], " ")
}
message := err.Error()
var guide chatWorkbookGuidance
switch {
case path == "doc +create" && (strings.Contains(message, "unknown flag: --content-file") || strings.Contains(message, "unknown flag: --content-format")):
guide = chatWorkbookGuidance{
"文档创建命令与参数不属于同一组",
"+create 是精简 Shortcut,不支持 --content-file/--content-format;需要从文件写入或指定 Markdown/JSONML 时应使用原生命令 doc create",
[]string{"改用 dws doc create,并保留 --content-file/--content-format", "只创建短文本时才继续使用 +create 的 --content"},
[]string{`dws doc create --name "<标题>" --content-file ./body.md --content-format markdown --format json`},
}
case path == "doc +inspect" && strings.Contains(message, "unknown flag: --include-info"):
guide = chatWorkbookGuidance{
"+inspect 不需要 --include-info",
"文档基本信息会由 +inspect 默认返回;移除 --include-info,只按需增加真实存在的 --include-history、--include-media、--include-comments、--include-permissions 或 --include-style",
[]string{"移除 --include-info", "只保留任务需要的 --include-* 参数"},
[]string{`dws doc +inspect --node <DOC_ID> --format json`},
}
case path == "doc +inspect" && strings.Contains(message, "unknown flag: --include-versions"):
guide = chatWorkbookGuidance{
"+inspect 不支持 --include-versions",
"版本历史对应的参数名是 --include-history;如果只需要版本列表,也可以使用 dws doc version list",
[]string{"将 --include-versions 改为 --include-history", "只查询版本时使用 doc version list"},
[]string{`dws doc +inspect --node <DOC_ID> --include-history --format json`, `dws doc version list --node <DOC_ID> --format json`},
}
case path == "doc +inspect" && strings.Contains(message, "unknown flag: --include"):
guide = chatWorkbookGuidance{
"+inspect 没有通用 --include 参数",
"请直接使用具体开关:--include-history、--include-media、--include-comments、--include-permissions 或 --include-style;文档块请使用 doc block list",
[]string{"把 --include <类型> 改成对应的具体 --include-* 开关", "需要 blockId 时改用 doc block list"},
[]string{`dws doc +inspect --node <DOC_ID> --include-history --format json`, `dws doc block list --node <DOC_ID> --format json`},
}
default:
return err
}
return apperrors.NewValidation(
guide.message+": "+guide.reason,
apperrors.WithReason("doc_parameter_family_mismatch"),
apperrors.WithHint(guide.actions[0]),
apperrors.WithActions(guide.actions...),
apperrors.WithExamples(guide.examples...),
apperrors.WithCause(err),
)
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -758,9 +703,6 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
if enriched := enrichChatWorkbookError(cmd, err); enriched != err {
return enriched
}
if enriched := enrichDocFlagError(cmd, err); enriched != err {
return enriched
}
// Common flag aliases and suggestions
suggestions := map[string]string{
+72 -87
View File
@@ -4713,8 +4713,7 @@
"availability": "available",
"avoid_when": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"confirmation": "not_required",
@@ -4766,8 +4765,7 @@
"avoid_when": {
"value": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"source": "internal/cli/schema_hints/selection/doc.json",
@@ -4778,8 +4776,7 @@
{
"value": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"source": "internal/cli/schema_hints/selection/doc.json",
@@ -4929,8 +4926,7 @@
"use_when": {
"value": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -4940,8 +4936,7 @@
{
"value": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -4976,8 +4971,7 @@
],
"use_when": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
]
},
"doc block list": {
@@ -5257,8 +5251,7 @@
"availability": "available",
"avoid_when": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"confirmation": "not_required",
"effect": "write",
@@ -5308,8 +5301,7 @@
"avoid_when": {
"value": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -5319,8 +5311,7 @@
{
"value": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -5466,7 +5457,7 @@
},
"use_when": {
"value": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -5475,7 +5466,7 @@
"candidates": [
{
"value": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -5509,7 +5500,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#doc.update_document_block"
],
"use_when": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
]
},
"doc comment create": {
@@ -6656,8 +6647,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"confirmation": "not_required",
"effect": "write",
@@ -6707,8 +6697,7 @@
},
"avoid_when": {
"value": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -6717,8 +6706,7 @@
"candidates": [
{
"value": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -6912,7 +6900,7 @@
},
"use_when": {
"value": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -6921,7 +6909,7 @@
"candidates": [
{
"value": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -6956,7 +6944,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#doc.reply_comment"
],
"use_when": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
]
},
"doc comment update": {
@@ -6964,7 +6952,8 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"confirmation": "not_required",
"effect": "write",
@@ -6979,14 +6968,14 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": "更新指定文档评论的文字内容和可选 @用户/@群。",
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。"
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。"
}
]
},
@@ -7008,21 +6997,23 @@
},
"avoid_when": {
"value": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。"
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。"
}
]
},
@@ -7078,7 +7069,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
@@ -7088,7 +7079,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。"
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。"
}
]
},
@@ -7181,7 +7172,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。"
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。"
}
]
},
@@ -7209,21 +7200,21 @@
},
"use_when": {
"value": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。"
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。"
}
]
}
@@ -7244,7 +7235,7 @@
"structured-hint:internal/cli/schema_hints/products/doc.json"
],
"use_when": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
]
},
"doc copy": {
@@ -7515,8 +7506,8 @@
"availability": "available",
"avoid_when": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"confirmation": "not_required",
"effect": "write",
@@ -7567,8 +7558,8 @@
"avoid_when": {
"value": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -7578,8 +7569,8 @@
{
"value": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -7790,7 +7781,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -7841,7 +7832,7 @@
},
"avoid_when": {
"value": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -7852,7 +7843,7 @@
"candidates": [
{
"value": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -9110,7 +9101,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"confirmation": "not_required",
"effect": "write",
@@ -9153,7 +9144,7 @@
},
"avoid_when": {
"value": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9162,7 +9153,7 @@
"candidates": [
{
"value": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9352,7 +9343,7 @@
},
"use_when": {
"value": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9361,7 +9352,7 @@
"candidates": [
{
"value": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9388,7 +9379,7 @@
"structured-hint:internal/cli/schema_hints/products/doc.json"
],
"use_when": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
]
},
"doc info": {
@@ -9937,8 +9928,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"confirmation": "not_required",
"effect": "read",
@@ -9987,8 +9977,7 @@
},
"avoid_when": {
"value": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9997,8 +9986,7 @@
"candidates": [
{
"value": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -10144,7 +10132,7 @@
},
"use_when": {
"value": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -10153,7 +10141,7 @@
"candidates": [
{
"value": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -10185,7 +10173,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#doc.download_doc_attachment"
],
"use_when": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
]
},
"doc media insert": {
@@ -14690,10 +14678,10 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"confirmation": "not_required",
"effect": "write",
@@ -14743,10 +14731,10 @@
},
"avoid_when": {
"value": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -14755,10 +14743,10 @@
"candidates": [
{
"value": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -14938,8 +14926,7 @@
"value": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -14950,8 +14937,7 @@
"value": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -14990,8 +14976,7 @@
"use_when": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
]
},
"doc upload": {
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:35727a6c324fbffde5271e96d972843abdf780391151ba858f6d04384268cd51",
"source_hash": "sha256:f239237a9b87fa5a95a0520b2e2f2a112e117b273de8418763ba0ecd8652b317",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"coverage": {
"surface_products": 26,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:35727a6c324fbffde5271e96d972843abdf780391151ba858f6d04384268cd51",
"source_hash": "sha256:f239237a9b87fa5a95a0520b2e2f2a112e117b273de8418763ba0ecd8652b317",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"source_files": 160,
"hint_files": 54,
+23 -28
View File
@@ -1,12 +1,12 @@
{
"version": 1,
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"source_hash": "sha256:807d11cf6c62a0d4aaca4b5a6b438e6654b2792d3a32b4e271032202d45ff8fa",
"source_hash": "sha256:eddcc39cfdf906e0b47c896abc081fd2b5fc81150023f99938deb9def8b07d9e",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:35727a6c324fbffde5271e96d972843abdf780391151ba858f6d04384268cd51",
"source_hash": "sha256:f239237a9b87fa5a95a0520b2e2f2a112e117b273de8418763ba0ecd8652b317",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"surface_products": 26,
"surface_tools": 875,
@@ -15383,8 +15383,8 @@
"availability": "available",
"avoid_when": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"canonical_path": "doc.create_document",
"cli_name": "create",
@@ -15537,7 +15537,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -15601,8 +15601,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"canonical_path": "doc.download_doc_attachment",
"cli_name": "download",
@@ -15624,7 +15623,7 @@
"risk": "low",
"title": "下载文档附件",
"use_when": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
]
},
{
@@ -15762,7 +15761,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"canonical_path": "doc.import_get",
"cli_name": "get",
@@ -15783,7 +15782,7 @@
"risk": "medium",
"title": "查询导入任务结果(手动兜底)",
"use_when": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
]
},
{
@@ -15793,8 +15792,7 @@
"availability": "available",
"avoid_when": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"canonical_path": "doc.insert_document_block",
@@ -15818,8 +15816,7 @@
"title": "插入块元素",
"use_when": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
]
},
{
@@ -16111,8 +16108,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"canonical_path": "doc.reply_comment",
"cli_name": "reply",
@@ -16134,7 +16130,7 @@
"risk": "medium",
"title": "回复评论",
"use_when": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
]
},
{
@@ -16804,7 +16800,8 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"canonical_path": "doc.update_comment",
"cli_name": "update",
@@ -16822,7 +16819,7 @@
"risk": "medium",
"title": "更新文档评论",
"use_when": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
]
},
{
@@ -16831,10 +16828,10 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"canonical_path": "doc.update_document",
"cli_name": "update",
@@ -16857,8 +16854,7 @@
"use_when": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
]
},
{
@@ -16868,8 +16864,7 @@
"availability": "available",
"avoid_when": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"canonical_path": "doc.update_document_block",
"cli_name": "update",
@@ -16891,7 +16886,7 @@
"risk": "medium",
"title": "更新块元素",
"use_when": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
]
},
{
+96 -90
View File
@@ -2321,8 +2321,8 @@
"availability": "available",
"avoid_when": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"canonical_path": "doc.create_document",
"cli_name": "create",
@@ -2384,8 +2384,8 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
]
}
],
@@ -2395,8 +2395,8 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type \u003ctype\u003e(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
]
},
"canonical_path": {
@@ -6450,7 +6450,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -6512,7 +6512,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
]
@@ -6523,7 +6523,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
]
@@ -7557,8 +7557,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"canonical_path": "doc.download_doc_attachment",
"cli_name": "download",
@@ -7618,8 +7617,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
]
}
],
@@ -7628,8 +7626,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
]
},
"canonical_path": {
@@ -7851,7 +7848,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
]
}
],
@@ -7860,7 +7857,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
]
}
},
@@ -8098,7 +8095,7 @@
"source": "reviewed_command_registry",
"title": "下载文档附件",
"use_when": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
]
},
"doc.download_file": {
@@ -10945,7 +10942,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"canonical_path": "doc.import_get",
"cli_name": "get",
@@ -11002,7 +10999,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
]
}
],
@@ -11011,7 +11008,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
]
},
"canonical_path": {
@@ -11267,7 +11264,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
]
}
],
@@ -11276,7 +11273,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
]
}
},
@@ -11395,7 +11392,7 @@
"source": "reviewed_command_registry",
"title": "查询导入任务结果(手动兜底)",
"use_when": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
]
},
"doc.insert_document_block": {
@@ -11420,8 +11417,7 @@
"availability": "available",
"avoid_when": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"canonical_path": "doc.insert_document_block",
@@ -11493,8 +11489,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
]
}
@@ -11505,8 +11500,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
]
},
@@ -11732,8 +11726,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
]
}
],
@@ -11743,8 +11736,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
]
}
},
@@ -12887,8 +12879,7 @@
"title": "插入块元素",
"use_when": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
]
},
"doc.list_comments": {
@@ -19717,8 +19708,7 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"canonical_path": "doc.reply_comment",
"cli_name": "reply",
@@ -19779,8 +19769,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
]
}
],
@@ -19789,8 +19778,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
]
},
"canonical_path": {
@@ -20068,7 +20056,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
]
}
],
@@ -20077,7 +20065,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
]
}
},
@@ -20738,7 +20726,7 @@
"source": "reviewed_command_registry",
"title": "回复评论",
"use_when": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
]
},
"doc.search_documents": {
@@ -37251,7 +37239,8 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"canonical_path": "doc.update_comment",
"cli_name": "update",
@@ -37270,7 +37259,7 @@
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": "更新指定文档评论的文字内容和可选 @用户/@群。"
@@ -37278,7 +37267,7 @@
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": "更新指定文档评论的文字内容和可选 @用户/@群。"
},
@@ -37302,20 +37291,22 @@
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
]
},
"canonical_path": {
@@ -37404,7 +37395,7 @@
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
@@ -37415,7 +37406,7 @@
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"dws doc comment update --node \u003cDOC_ID\u003e --comment-key \u003cCOMMENT_KEY\u003e --content \"已按最新数据修正\" --format json",
@@ -37503,7 +37494,7 @@
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"selected": false,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": true
@@ -37555,20 +37546,20 @@
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
]
}
},
@@ -38093,7 +38084,7 @@
"source": "reviewed_command_registry",
"title": "更新文档评论",
"use_when": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
]
},
"doc.update_document": {
@@ -38120,10 +38111,10 @@
"agent_summary_source": "dws-agent-selection/doc",
"availability": "available",
"avoid_when": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"canonical_path": "doc.update_document",
"cli_name": "update",
@@ -38198,10 +38189,10 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
]
}
],
@@ -38210,10 +38201,10 @@
"review_reason": "人工对齐 Runtime 的 10000 字符自动分片、append/overwrite 动态门禁与根 Skill 的写后回读流程;不改变参数和安全事实。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
]
},
"canonical_path": {
@@ -38469,8 +38460,7 @@
"value": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
]
}
],
@@ -38481,8 +38471,7 @@
"value": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
]
}
},
@@ -39097,8 +39086,23 @@
"type": "integer"
},
"mode": {
"cli_required": true,
"description": "更新模式: overwrite=覆盖, append=追加 (必填)",
"field_provenance": {
"cli_required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_hard_required",
"value": true
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_hard_required",
"value": true
},
"description": {
"candidates": [
{
@@ -39142,8 +39146,14 @@
"required": {
"candidates": [
{
"precedence": "inference",
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_hard_required",
"value": true
},
{
"precedence": "inference",
"selected": false,
"source": "usage_required_inference",
"value": true
},
@@ -39154,9 +39164,9 @@
"value": false
}
],
"precedence": "inference",
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "usage_required_inference",
"source": "cobra_hard_required",
"value": true
},
"required_when": {
@@ -39522,8 +39532,7 @@
"use_when": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
]
},
"doc.update_document_block": {
@@ -39548,8 +39557,7 @@
"availability": "available",
"avoid_when": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"canonical_path": "doc.update_document_block",
"cli_name": "update",
@@ -39619,8 +39627,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
]
}
],
@@ -39630,8 +39637,7 @@
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
]
},
"canonical_path": {
@@ -39853,7 +39859,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
]
}
],
@@ -39862,7 +39868,7 @@
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/doc.json",
"value": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
]
}
},
@@ -40704,7 +40710,7 @@
"source": "reviewed_command_registry",
"title": "更新块元素",
"use_when": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
]
},
"doc.update_permission": {
+22 -27
View File
@@ -89,8 +89,8 @@
],
"avoid_when": [
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type <type>(勿用 doc create)",
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
"导入本地 Word/Markdown 为在线文档用 dws doc import,并显式提供 --folder 或 --workspace;不要用已迁移的 doc upload --convert"
"只管理知识库空节点或层级时用 wiki node create;本命令侧重创建并写入 adoc",
"导入本地 Word/Markdown 并保留服务端转换语义时用 dws doc import;不要用自写分片脚本替代原生 create"
],
"examples": [
"dws doc create --name \"项目周报\" --format json",
@@ -205,7 +205,7 @@
"用户明确要求用 doc delete 兼容入口将文档/文件移入回收站,且已确认目标时"
],
"avoid_when": [
"常规文件删除使用 dws drive delete;不要继续选择已弃用的 doc delete 兼容入口",
"常规文件删除优先 dws drive delete;本入口仅为兼容",
"用户未确认或目标不清时不要删",
"删块用 doc block delete;删评论用 doc comment delete"
],
@@ -250,11 +250,10 @@
"doc.download_doc_attachment": {
"agent_summary": "获取文档附件的临时下载链接",
"use_when": [
"获取文档正文中附件的临时下载 URL 时;resourceId 必须来自本次 block list 返回的 attachment 块"
"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"
],
"avoid_when": [
"下载钉盘普通文件用 drive download;导出在线文档用 doc export",
"不要把 blockId/nodeId 当作 resourceId,也不要编造 --output:本命令返回临时 downloadUrl"
"下载钉盘普通文件用 drive download;导出在线文档用 doc export"
],
"examples": [
"dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID> --format json"
@@ -352,10 +351,10 @@
"doc.import_get": {
"agent_summary": "根据 taskId 查询文档导入任务的执行结果",
"use_when": [
"仅在 doc import 已返回真实 taskId 且自动轮询超时/中断后,查询导入任务结果时"
"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"
],
"avoid_when": [
"发起导入用 doc import;不要先调用 import get,也不要使用示例占位 taskId"
"发起导入用 doc import(若入口可用);不要用本命令代替导入"
],
"examples": [
"dws doc import get --task-id <TASK_ID> --format json"
@@ -374,13 +373,11 @@
"agent_summary": "向文档插入块元素",
"use_when": [
"用户明确要求在文档中插入新块(段落/标题/列表等)时;简单段落/标题用 --text/--heading,复杂块用 --element JSON",
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替",
"相对插入时先 block list 获取真实 blockId,再同时传 --ref-block 与 --where before|after"
"用户要求有序列表块时使用 JSONML p.list.isOrdered=true(同一 listId)或等价原生 orderedList 结构,不能用普通 Markdown 代替"
],
"avoid_when": [
"仅在用户没有指定块操作、只要整篇追加 Markdown 时优先 doc update --mode append;显式 insert/标题块/列表块不能折叠",
"插入本地文件附件优先 doc media insert;不要为普通附件手写 attachment JSON",
"整段 Markdown 或长内容优先 doc update --mode append,不要拆成大量 block insert",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update"
],
"examples": [
@@ -611,11 +608,10 @@
"doc.reply_comment": {
"agent_summary": "回复文档评论",
"use_when": [
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 必须来自本次 list/create 返回"
"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"
],
"avoid_when": [
"新建评论用 create/create-inline;删评论用 delete",
"不要把 commentId、blockId 或示例占位符传给 --comment-key"
"新建评论用 create/create-inline;删评论用 delete"
],
"examples": [
"dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"同意\" --mentioned-open-conversation-id <openConversationId> --format json",
@@ -725,17 +721,18 @@
"doc.update_comment": {
"agent_summary": "更新指定文档评论的文字内容和可选 @用户/@群。",
"use_when": [
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"
"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id;执行后必须用 comment list 回查同一 commentKey 的目标字段"
],
"avoid_when": [
"删除评论用 delete;回复用 reply"
"删除评论用 delete;回复用 reply",
"响应为 null/空对象或回查仍是旧正文时不能判成功,必须报告更新未生效或部分完成"
],
"examples": [
"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 \"请群内确认\" --mentioned-open-conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"review_reason": "人工对齐评论更新的业务结果与 comment list 回查语义;null/空响应不能单独证明成功,不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"internal/cli/schema_command_registry.json#doc.update_comment",
"cobra-help:dws doc comment update --help",
@@ -749,14 +746,13 @@
"use_when": [
"用户要向已有 adoc 追加长、多行或文件内容时用 --mode append + --content-file;CLI 自动分片",
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
"长文本、多行内容或表格优先写入 UTF-8 文件并使用 --content-file,避免 shell 转义损坏"
"append 且要插到第 N 个 block 前时加 --index N(先 block list)"
],
"avoid_when": [
"目标不是 adoc 或只要改单个块时改用 doc block update",
"覆盖模式用户未确认前不要执行;先 --dry-run 预览,确认后再加 --yes",
"--content 与 --content-file 二选一;--index 仅用于 mode=append",
"创建新文档用 doc create,不要用 update 冒充创建"
"只在末尾补一小段纯文本优先 +doc-append;只改一个块用 doc block update",
"目标不是 adoc 时按 drive info 的 extension 切对应产品",
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
"创建新文档用 doc create;不要预先手工分片或循环重试覆盖"
],
"examples": [
"dws doc update --node <DOC_ID> --content \"# 追加内容\" --mode append --format json",
@@ -777,12 +773,11 @@
"doc.update_document_block": {
"agent_summary": "更新文档中的指定块",
"use_when": [
"修改已有块的文本/标题/样式时;blockId 必须来自目标文档本次 block list 结果"
"修改已有块的文本/标题/样式(已知 blockId)时"
],
"avoid_when": [
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
"不要跨文档复用 blockId,也不要把 nodeId/commentKey 当作 blockId"
"改文档显示名用 rename;整篇覆盖用 update overwrite"
],
"examples": [
"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"
-18
View File
@@ -363,24 +363,6 @@ func TestSuggestBusinessHintChatRecovery(t *testing.T) {
}
}
func TestSuggestBusinessHintDocRecovery(t *testing.T) {
cases := []struct{ message, want string }{
{"nodeId not found", "drive search"},
{"blockId 不存在", "block list"},
{"commentKey invalid", "comment list/create"},
{"resourceId not found", "attachment"},
{"workspaceId invalid", "wiki space list"},
{"folderId invalid", "dentryId"},
{"templateId not found", "template search"},
{"version 不存在", "version list"},
}
for _, tc := range cases {
if got := SuggestBusinessHint(map[string]any{"message": tc.message}); !strings.Contains(got, tc.want) {
t.Errorf("SuggestBusinessHint(%q) = %q, want containing %q", tc.message, got, tc.want)
}
}
}
func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) {
oldHost := hostControlProvider
oldBrowser := patBrowserProvider
-16
View File
@@ -340,22 +340,6 @@ func suggestForBusinessErrorText(body map[string]any) string {
switch {
case strings.Contains(msg, "搜索内容不能为空"):
return "请提供非空搜索关键词: dws doc search --query \"关键词\""
case strings.Contains(msg, "nodeId") && (strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")):
return "目标文档 nodeId 不存在或当前账号不可见。请用 dws drive search 或 dws wiki node search 重新获取真实 nodeId;不要复用示例占位符。"
case strings.Contains(msg, "blockId") && (strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")):
return "目标 blockId 不存在或已变化。请重新执行 dws doc block list --node <DOC_ID> --format json,并从本次结果取 blockId。"
case strings.Contains(msg, "commentKey") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "commentKey 无效或评论已不存在。请从 dws doc comment list/create 的当前返回结果中提取 commentKey,不要使用 commentId 或占位符。"
case strings.Contains(msg, "resourceId") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "resourceId 无效。请用 dws doc block list 查找 attachment 块并读取真实 resourceId;不要把 blockId 当作 resourceId。"
case strings.Contains(msg, "workspaceId") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "workspaceId 无效或无权访问。请用 dws wiki space list --type myWikiSpace --format json 获取当前账号可用的 workspaceId。"
case strings.Contains(msg, "folderId") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "folderId 无效。doc 的 --folder 需要文档文件夹 nodeId/URL,不是 drive 的纯数字 dentryId;请重新列出目标空间节点。"
case strings.Contains(msg, "templateId") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "templateId 无效或模板不可见。请先用 dws doc template search --query <关键词> --format json 获取当前账号可用的真实 templateId。"
case strings.Contains(msg, "version") && (strings.Contains(msg, "不存在") || strings.Contains(msg, "not found")):
return "目标版本不存在。请先执行 dws doc version list --node <DOC_ID> --format json,并从当前版本列表选择可回滚版本。"
case strings.Contains(msg, "User has no permission to access this email"):
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(msg, "频率超限") || strings.Contains(msg, "rate limit"):
+2 -177
View File
@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
@@ -133,8 +132,6 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
var allRecords []any
page := 0
lastCursor := ""
seenCursors := map[string]struct{}{}
stopReason := ""
for {
page++
@@ -204,19 +201,6 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
lastCursor = ""
break
}
if current, _ := toolArgs["cursor"].(string); cursor == current {
lastCursor = cursor
stopReason = "cursor_not_advanced"
fmt.Fprintf(os.Stderr, "[pagination] cursor did not advance (%q), stopping to avoid an infinite loop. Resume with --cursor %q after checking the service response.\n", cursor, cursor)
break
}
if _, exists := seenCursors[cursor]; exists {
lastCursor = cursor
stopReason = "cursor_cycle_detected"
fmt.Fprintf(os.Stderr, "[pagination] cursor cycle detected (%q), stopping to avoid an infinite loop. Resume with --cursor %q after checking the service response.\n", cursor, cursor)
break
}
seenCursors[cursor] = struct{}{}
// Check page limit (0 = unlimited)
if pageLimit > 0 && page >= pageLimit {
@@ -239,10 +223,6 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
if lastCursor != "" {
mergedData["cursor"] = lastCursor
mergedData["hasMore"] = true
if stopReason != "" {
mergedData["incomplete"] = true
mergedData["stopReason"] = stopReason
}
} else {
mergedData["hasMore"] = false
}
@@ -801,20 +781,6 @@ func isAitableRetryableError(err error) bool {
}
msg := strings.ToLower(err.Error())
// 确定性业务错误优先于外层 category/retryable 标记,避免对不存在的资源、
// 参数错误或权限错误做无意义重试。部分 MCP 响应会同时携带
// "category: internal" 或 "retryable: true",因此必须先判定终止类错误。
nonRetryablePatterns := []string{
"input_error", "user_error", "auth_error", "permission denied",
"invalid parameter", "invalid argument", "bad request",
"not found", "no record", "does not exist", "不存在",
}
for _, p := range nonRetryablePatterns {
if strings.Contains(msg, p) {
return false
}
}
// 网络瞬态错误
retryablePatterns := []string{
"timeout", "deadline exceeded", "connection reset",
@@ -848,154 +814,17 @@ func isAitableRetryableError(err error) bool {
func parseFieldsJSON(raw string) ([]any, error) {
var fields []any
if err := json.Unmarshal([]byte(raw), &fields); err == nil {
return validateAitableCreateFields(fields)
return fields, nil
}
var wrapper map[string]any
if err := json.Unmarshal([]byte(raw), &wrapper); err == nil {
if arr, ok := wrapper["fields"].([]any); ok {
return validateAitableCreateFields(arr)
return arr, nil
}
}
return nil, fmt.Errorf("--fields JSON parse failed: expect a JSON array [...]\n hint: example: '[{\"fieldName\":\"名称\",\"type\":\"text\"}]'")
}
// validateAitableCreateFields catches stable field-contract mistakes before MCP dispatch
// and returns a directly actionable correction. It intentionally does not normalize aliases:
// silently changing a field type can alter business semantics.
func validateAitableCreateFields(fields []any) ([]any, error) {
if len(fields) > 15 {
return nil, fmt.Errorf("--fields contains %d items; AI Table accepts at most 15 fields per request\n hint: split the fields into batches of 15 or fewer", len(fields))
}
for i, rawField := range fields {
field, ok := rawField.(map[string]any)
if !ok {
return nil, fmt.Errorf("--fields[%d] must be a JSON object\n hint: example: {\"fieldName\":\"状态\",\"type\":\"singleSelect\"}", i)
}
if _, hasAlias := field["fieldType"]; hasAlias {
return nil, fmt.Errorf("--fields[%d] uses unsupported key fieldType\n hint: use fieldName + type, for example {\"fieldName\":\"状态\",\"type\":\"singleSelect\"}", i)
}
fieldName, nameOK := field["fieldName"].(string)
if !nameOK || strings.TrimSpace(fieldName) == "" {
return nil, fmt.Errorf("--fields[%d].fieldName must be a non-empty string\n hint: example: {\"fieldName\":\"任务名称\",\"type\":\"text\"}", i)
}
if strings.ContainsAny(fieldName, "\r\n") {
return nil, fmt.Errorf("--fields[%d].fieldName must not contain line breaks\n hint: use a single-line field name with at most 100 characters", i)
}
if utf8.RuneCountInString(fieldName) > 100 {
return nil, fmt.Errorf("--fields[%d].fieldName is %d characters; maximum is 100\n hint: shorten the field name before retrying", i, utf8.RuneCountInString(fieldName))
}
fieldType, typeOK := field["type"].(string)
fieldType = strings.TrimSpace(fieldType)
if !typeOK || fieldType == "" {
return nil, fmt.Errorf("--fields[%d].type must be a non-empty string\n hint: common types: text, number, singleSelect, multipleSelect, date, user, attachment", i)
}
if strings.EqualFold(strings.TrimSpace(fieldType), "select") {
return nil, fmt.Errorf("--fields[%d].type %q is not a valid AI Table field type\n hint: use \"singleSelect\" for single choice or \"multipleSelect\" for multiple choice; do not use \"select\"", i, fieldType)
}
if !aitableFieldTypes[fieldType] {
return nil, fmt.Errorf("--fields[%d].type %q is unsupported\n hint: use a documented type such as text, number, singleSelect, multipleSelect, date, user, attachment, url, or richText", i, fieldType)
}
if fieldType == "primaryDoc" && i != 0 {
return nil, fmt.Errorf("--fields[%d] uses primaryDoc outside the first column\n hint: primaryDoc is allowed only as --fields[0]", i)
}
config, hasConfig := field["config"]
var configMap map[string]any
if hasConfig {
var configOK bool
configMap, configOK = config.(map[string]any)
if !configOK {
return nil, fmt.Errorf("--fields[%d].config must be a JSON object\n hint: example: {\"options\":[{\"name\":\"高\"},{\"name\":\"低\"}]}", i)
}
}
if err := validateAitableFieldConfig(i, fieldType, configMap); err != nil {
return nil, err
}
}
return fields, nil
}
var aitableFieldTypes = map[string]bool{
"text": true, "number": true, "singleSelect": true, "multipleSelect": true, "date": true,
"currency": true, "user": true, "department": true, "group": true, "progress": true,
"rating": true, "checkbox": true, "attachment": true, "url": true, "richText": true,
"telephone": true, "email": true, "idCard": true, "barcode": true, "geolocation": true,
"address": true, "primaryDoc": true, "formula": true, "filterUp": true, "lookup": true,
"unidirectionalLink": true, "bidirectionalLink": true, "creator": true, "lastModifier": true,
"createdTime": true, "lastModifiedTime": true,
}
func validateAitableFieldConfig(index int, fieldType string, config map[string]any) error {
if fieldType == "singleSelect" || fieldType == "multipleSelect" {
if config == nil {
return fmt.Errorf("--fields[%d].config.options is required for %s\n hint: use {\"options\":[{\"name\":\"选项A\"},{\"name\":\"选项B\"}]}", index, fieldType)
}
options, ok := config["options"].([]any)
if !ok || len(options) == 0 {
return fmt.Errorf("--fields[%d].config.options must be a non-empty JSON array\n hint: example: {\"options\":[{\"name\":\"高\"},{\"name\":\"低\"}]}", index)
}
for optionIndex, rawOption := range options {
option, ok := rawOption.(map[string]any)
if !ok {
return fmt.Errorf("--fields[%d].config.options[%d] must be a JSON object\n hint: each option must look like {\"name\":\"选项名\"}", index, optionIndex)
}
name, ok := option["name"].(string)
if !ok || strings.TrimSpace(name) == "" {
return fmt.Errorf("--fields[%d].config.options[%d].name must be a non-empty string\n hint: example: {\"name\":\"高\"}", index, optionIndex)
}
}
}
if config == nil {
if fieldType == "formula" {
return fmt.Errorf("--fields[%d].config.formula is required for formula\n hint: example: {\"formula\":\"[单价] * [数量]\"}", index)
}
if fieldType == "unidirectionalLink" || fieldType == "bidirectionalLink" {
return fmt.Errorf("--fields[%d].config.linkedTableId is required for %s\n hint: get the target table ID first, then use {\"linkedTableId\":\"<TABLE_ID>\",\"multiple\":true}", index, fieldType)
}
return nil
}
if formatter, ok := config["formatter"].(string); ok && formatter != "" {
allowed := map[string]map[string]bool{
"number": {"INT": true, "FLOAT_1": true, "FLOAT_2": true, "FLOAT_3": true, "FLOAT_4": true, "THOUSAND": true, "THOUSAND_FLOAT": true, "PERCENT": true, "PERCENT_FLOAT": true},
"date": {"YYYY-MM-DD": true, "YYYY-MM-DD HH:mm": true, "YYYY-MM-DD HH:mm:ss": true, "YYYY/MM/DD": true, "YYYY/MM/DD HH:mm": true},
"currency": {"INT": true, "FLOAT_1": true, "FLOAT_2": true, "FLOAT_3": true, "FLOAT_4": true},
"progress": {"PERCENT": true},
}
if typeAllowed, applies := allowed[fieldType]; applies && !typeAllowed[formatter] {
return fmt.Errorf("--fields[%d].config.formatter %q is invalid for %s\n hint: run 'dws aitable table create --help' and use a formatter listed for %s", index, formatter, fieldType, fieldType)
}
}
if fieldType == "currency" {
currency, _ := config["currencyType"].(string)
currencies := map[string]bool{"CNY": true, "HKD": true, "USD": true, "EUR": true, "GBP": true, "MOP": true, "VND": true, "JPY": true, "KRW": true, "AED": true, "AUD": true, "BRL": true, "CAD": true, "CHF": true, "INR": true, "IDR": true, "MXN": true, "MYR": true, "PHP": true, "PLN": true, "RUB": true, "SGD": true, "THB": true, "TRY": true, "TWD": true}
if currency != "" && !currencies[currency] {
return fmt.Errorf("--fields[%d].config.currencyType %q is unsupported\n hint: use an ISO currency from table create --help, for example CNY, USD, EUR, JPY, or HKD", index, currency)
}
}
if fieldType == "rating" {
if max, ok := config["max"].(float64); ok && (max < 1 || max > 10) {
return fmt.Errorf("--fields[%d].config.max must be between 1 and 10 for rating\n hint: example: {\"min\":1,\"max\":5,\"icon\":\"star\"}", index)
}
}
if multiple, exists := config["multiple"]; exists {
if _, ok := multiple.(bool); !ok {
return fmt.Errorf("--fields[%d].config.multiple must be true or false\n hint: use a JSON boolean without quotes, for example {\"multiple\":false}", index)
}
}
if fieldType == "formula" {
formula, _ := config["formula"].(string)
if strings.TrimSpace(formula) == "" {
return fmt.Errorf("--fields[%d].config.formula is required for formula\n hint: example: {\"formula\":\"[单价] * [数量]\"}", index)
}
}
if fieldType == "unidirectionalLink" || fieldType == "bidirectionalLink" {
linkedTableID, _ := config["linkedTableId"].(string)
if strings.TrimSpace(linkedTableID) == "" {
return fmt.Errorf("--fields[%d].config.linkedTableId is required for %s\n hint: get the target table ID first, then use {\"linkedTableId\":\"<TABLE_ID>\",\"multiple\":true}", index, fieldType)
}
}
return nil
}
// resolveFormUpdateTitle 折叠 form update 的 --title / --name 别名为单个 title 值。
// 优先 --title,未设置时回退到 --name;两者都未传返回 ""。
// 抽出独立函数便于单测覆盖。
@@ -1541,10 +1370,6 @@ config 结构参考:
} else {
return fmt.Errorf("must specify either --fields OR both --name and --type")
}
fields, err := validateAitableCreateFields(fields)
if err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
@@ -88,87 +88,6 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
}
}
func TestAitableTableCreateInvalidFieldContractDoesNotDispatchMCP(t *testing.T) {
oldDeps, oldArgs := deps, os.Args
t.Cleanup(func() { deps, os.Args = oldDeps, oldArgs })
longName := strings.Repeat("字", 101)
manyFields := make([]string, 16)
for i := range manyFields {
manyFields[i] = fmt.Sprintf(`{"fieldName":"F%d","type":"text"}`, i)
}
cases := []struct {
name string
fields string
hint string
}{
{"field must be object", `[1]`, "must be a JSON object"},
{"fieldType key", `[{"fieldName":"状态","fieldType":"singleSelect"}]`, "fieldName + type"},
{"missing fieldName", `[{"type":"text"}]`, "fieldName must be"},
{"empty fieldName", `[{"fieldName":" ","type":"text"}]`, "fieldName must be"},
{"fieldName line break", `[{"fieldName":"任务\n名称","type":"text"}]`, "must not contain line breaks"},
{"fieldName too long", fmt.Sprintf(`[{"fieldName":%q,"type":"text"}]`, longName), "maximum is 100"},
{"missing type", `[{"fieldName":"任务"}]`, "type must be"},
{"unknown type", `[{"fieldName":"任务","type":"string"}]`, "type \"string\" is unsupported"},
{"select alias", `[{"fieldName":"状态","type":"select"}]`, "singleSelect"},
{"too many fields", `[` + strings.Join(manyFields, ",") + `]`, "at most 15"},
{"config scalar", `[{"fieldName":"任务","type":"text","config":"bad"}]`, "config must be a JSON object"},
{"single select missing options", `[{"fieldName":"状态","type":"singleSelect"}]`, "config.options is required"},
{"select options not array", `[{"fieldName":"状态","type":"singleSelect","config":{"options":{}}}]`, "non-empty JSON array"},
{"select options empty", `[{"fieldName":"状态","type":"singleSelect","config":{"options":[]}}]`, "non-empty JSON array"},
{"select option scalar", `[{"fieldName":"状态","type":"singleSelect","config":{"options":["高"]}}]`, "must be a JSON object"},
{"select option empty name", `[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":""}]}}]`, "name must be a non-empty string"},
{"number formatter", `[{"fieldName":"金额","type":"number","config":{"formatter":"CURRENCY_YUAN"}}]`, "invalid for number"},
{"date formatter", `[{"fieldName":"日期","type":"date","config":{"formatter":"MM-DD-YYYY"}}]`, "invalid for date"},
{"currency formatter", `[{"fieldName":"金额","type":"currency","config":{"formatter":"THOUSAND"}}]`, "invalid for currency"},
{"currency type", `[{"fieldName":"金额","type":"currency","config":{"currencyType":"RMB"}}]`, "currencyType \"RMB\" is unsupported"},
{"progress formatter", `[{"fieldName":"进度","type":"progress","config":{"formatter":"PERCENT_FLOAT"}}]`, "invalid for progress"},
{"rating max", `[{"fieldName":"评分","type":"rating","config":{"max":11}}]`, "between 1 and 10"},
{"multiple must be boolean", `[{"fieldName":"负责人","type":"user","config":{"multiple":"false"}}]`, "must be true or false"},
{"formula missing formula", `[{"fieldName":"总价","type":"formula","config":{}}]`, "config.formula is required"},
{"link missing table", `[{"fieldName":"关联","type":"unidirectionalLink","config":{"multiple":true}}]`, "linkedTableId is required"},
{"primary doc not first", `[{"fieldName":"名称","type":"text"},{"fieldName":"文档","type":"primaryDoc"}]`, "only as --fields[0]"},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
for _, args := range [][]string{
{"table", "create", "--base-id=b", "--name=n", "--fields=" + test.fields},
{"field", "create", "--base-id=b", "--table-id=t", "--fields=" + test.fields},
} {
caller := &aitableTestCaller{}
err := runAitableCoverageCommand(t, caller, args...)
if err == nil || !strings.Contains(err.Error(), test.hint) {
t.Fatalf("invalid fields error = %v, want hint %q", err, test.hint)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid fields dispatched %d MCP call(s), want zero", len(caller.calls))
}
}
})
}
for _, test := range []struct {
name string
args []string
hint string
}{
{"typed unknown type", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=字段", "--type=string"}, "type \"string\" is unsupported"},
{"typed select alias", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=状态", "--type=select"}, "singleSelect"},
{"typed select no options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=状态", "--type=singleSelect"}, "config.options is required"},
{"typed invalid formatter", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=金额", "--type=number", `--config={"formatter":"CURRENCY_YUAN"}`}, "invalid for number"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &aitableTestCaller{}
err := runAitableCoverageCommand(t, caller, test.args...)
if err == nil || !strings.Contains(err.Error(), test.hint) {
t.Fatalf("typed field error = %v, want hint %q", err, test.hint)
}
if len(caller.calls) != 0 {
t.Fatalf("typed invalid field dispatched %d MCP call(s), want zero", len(caller.calls))
}
})
}
}
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
oldDeps, oldArgs, oldStdin, oldSleep := deps, os.Args, os.Stdin, helperSleep
t.Cleanup(func() {
+3 -24
View File
@@ -180,9 +180,7 @@ func TestCrossPlatformCoverageAitableViewConfigAndHelpers(t *testing.T) {
for _, tc := range []struct {
err error
want bool
}{{nil, false}, {errors.New("timeout"), true}, {errors.New("SYSTEM_ERROR"), true}, {errors.New("retryable: true"), true}, {errors.New("bad request"), false},
{errors.New(`category: internal_error, type: INPUT_ERROR, retryable: true, message: no record`), false},
{errors.New(`category: internal_error, retryable: true, message: resource not found`), false}} {
}{{nil, false}, {errors.New("timeout"), true}, {errors.New("SYSTEM_ERROR"), true}, {errors.New("retryable: true"), true}, {errors.New("bad request"), false}} {
if got := isAitableRetryableError(tc.err); got != tc.want {
t.Errorf("isAitableRetryableError(%v) = %v", tc.err, got)
}
@@ -250,26 +248,15 @@ func TestCrossPlatformCoverageAitableViewConfigAndHelpers(t *testing.T) {
t.Fatalf("typed-only update = %#v, %v", merged, err)
}
for _, raw := range []string{`[{"fieldName":"N","type":"text"}]`, `{"fields":[{"fieldName":"N","type":"text"}]}`, `{}`, `{`} {
for _, raw := range []string{`[1]`, `{"fields":[1]}`, `{}`, `{`} {
fields, err := parseFieldsJSON(raw)
if (strings.HasPrefix(raw, `[{`) || strings.Contains(raw, "fields")) && (err != nil || len(fields) != 1) {
if (raw == `[1]` || strings.Contains(raw, "fields")) && (err != nil || len(fields) != 1) {
t.Errorf("parseFieldsJSON(%q) = %#v, %v", raw, fields, err)
}
if (raw == `{}` || raw == `{`) && err == nil {
t.Errorf("parseFieldsJSON(%q) should fail", raw)
}
}
for _, tc := range []struct {
raw string
hint string
}{
{`[{"fieldName":"状态","type":"select"}]`, "singleSelect"},
{`[{"fieldName":"状态","fieldType":"singleSelect"}]`, "fieldName + type"},
} {
if _, err := parseFieldsJSON(tc.raw); err == nil || !strings.Contains(err.Error(), tc.hint) {
t.Errorf("parseFieldsJSON(%q) error = %v, want hint %q", tc.raw, err, tc.hint)
}
}
}
func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing.T) {
@@ -325,12 +312,4 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil {
t.Fatal("first-page pagination error should fail")
}
caller = &aitableTestCaller{responses: []string{
`{"data":{"records":[{"id":1}],"nextCursor":"same"}}`,
`{"data":{"records":[{"id":2}],"nextCursor":"same"}}`,
}}
out = installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil || !strings.Contains(out.String(), `"stopReason"`) || !strings.Contains(out.String(), `"cursor_not_advanced"`) {
t.Fatalf("repeated cursor guard = %q, %v", out.String(), err)
}
}
+2 -2
View File
@@ -3776,8 +3776,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return err
}
iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id"))
if err := ValidateChatMediaID(iconMediaID); err != nil {
return fmt.Errorf("invalid --icon-media-id: %w", err)
if iconMediaID == "" {
return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 请使用上游媒体上传能力返回的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传命令")
}
return callMCPToolOnServer("im", "update_group_icon", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
@@ -85,22 +85,6 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing.
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconRejectsLocalPathBeforeMCP(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller,
"group", "update-icon", "--group=cid", "--icon-media-id=./logo.png")
if err == nil {
t.Fatal("update group icon with local path succeeded, want validation error")
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
+1 -24
View File
@@ -4,12 +4,7 @@
package helpers
import (
"context"
"fmt"
"path/filepath"
"strings"
)
import "context"
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
// metadata to built-in semantic Shortcuts. It remains an alias so the native
@@ -46,21 +41,3 @@ func BuildConversationFileContent(
) (string, error) {
return buildConversationFileContent(dentryID, spaceID, meta)
}
// ValidateChatMediaID rejects values that are deterministically not an
// uploaded DingTalk mediaId before an MCP request is dispatched.
func ValidateChatMediaID(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("mediaId 不能为空")
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "file://") || filepath.IsAbs(value) ||
strings.ContainsAny(value, `/\\`) || strings.HasPrefix(lower, "dentry") {
return fmt.Errorf("%q 是本地文件路径或文件标识,不是 mediaId;请使用可信上游返回的 mediaId,DWS CLI 当前不能把本地图片转换为群头像 mediaId", value)
}
if value[0] != '@' && value[0] != '$' {
return fmt.Errorf("%q 不是有效 mediaId:群头像 mediaId 应以 @ 或 $ 开头;不要传本地路径、dentryId 或 uploadKey", value)
}
return nil
}
+2 -25
View File
@@ -33,27 +33,6 @@ func SetHTTPPutFile(fn func(ctx context.Context, url string, headers map[string]
httpPutFile = fn
}
func callDocCommentUpdate(toolArgs map[string]any) error {
text, err := callMCPToolReturnTextOnServer(context.Background(), "doc-comment", "update_comment", toolArgs)
if err != nil {
return err
}
trimmed := strings.TrimSpace(text)
if trimmed == "" || trimmed == "null" {
return &CLIError{
Code: CodeMCPToolError,
Message: "评论更新接口未返回可验证的更新结果,不能判定为成功",
Suggestion: "请用 dws doc comment list --node <DOC_ID> --format json 回查评论内容;若未变化,保留原 commentKey 并重试",
}
}
var payload any
if json.Unmarshal([]byte(trimmed), &payload) == nil {
return deps.Out.PrintJSON(payload)
}
deps.Out.PrintRaw(text)
return nil
}
func docVersionExists(ctx context.Context, nodeID string, version int) (bool, error) {
// 注意: 不传 maxResults —— 服务端实际接受的上限小于 schema 声明的 1-50,
// 传大值会直接报错 (与悟空实现一致: 默认分页大小 + 游标翻页)。
@@ -1670,8 +1649,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
updateCmd.Flags().String("markdown", "", "已弃用,请使用 --content 代替")
_ = updateCmd.Flags().MarkHidden("markdown")
updateCmd.Flags().String("mode", "", "更新模式: overwrite=覆盖, append=追加 (必填)")
// Kept out of Cobra's generic required-flag validator so doc local
// preflight can return a structured, actionable error before MCP dispatch.
_ = updateCmd.MarkFlagRequired("mode")
updateCmd.Flags().Int("index", -1, "插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾")
updateCmd.Flags().Bool("yes", false, "确认执行破坏性写入 (仅 --mode overwrite 需要)")
updateCmd.Flags().Bool("dry-run", false, "预览覆盖写入差异,不调用远端 update")
@@ -2065,7 +2043,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
if err := appendCommentGroupMentions(cmd, toolArgs); err != nil {
return err
}
return callDocCommentUpdate(toolArgs)
return callMCPToolOnServer("doc-comment", "update_comment", toolArgs)
},
}
commentUpdateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
@@ -2921,7 +2899,6 @@ CLI 内部自动完成全部流程:
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
attachDocLocalPreflight(root)
return root
}
@@ -18,7 +18,6 @@ import (
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -31,29 +30,12 @@ type docCommentMutationCall struct {
}
type docCommentMutationCaller struct {
calls []docCommentMutationCall
response string
calls []docCommentMutationCall
}
func (c *docCommentMutationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, docCommentMutationCall{productID: productID, toolName: toolName, args: args})
response := c.response
if response == "" {
response = `{}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func TestDocCommentUpdateRejectsNullAcknowledgement(t *testing.T) {
caller := &docCommentMutationCaller{response: `null`}
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
"comment", "update", "--node", "doc-1", "--comment-key", "comment-1", "--content", "updated")
if err == nil || !strings.Contains(err.Error(), "未返回可验证的更新结果") {
t.Fatalf("error = %v, want unverifiable update error", err)
}
if len(caller.calls) != 1 {
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
}
func (*docCommentMutationCaller) Format() string { return "json" }
-446
View File
@@ -1,446 +0,0 @@
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// attachDocLocalPreflight wraps every doc leaf before its RunE reaches MCP.
// Only facts provable from local argv/filesystem state belong here; resource
// existence, permissions and business state remain server-owned.
func attachDocLocalPreflight(root *cobra.Command) {
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
for _, child := range cmd.Commands() {
walk(child)
}
if cmd.RunE == nil {
return
}
original := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if err := validateDocLocalArgs(c); err != nil {
return err
}
return original(c, args)
}
}
walk(root)
}
func docLocalError(cmd *cobra.Command, code, message, suggestion string) error {
return &CLIError{Code: code, Message: message, Suggestion: suggestion, Operation: strings.TrimPrefix(cmd.CommandPath(), "dws ")}
}
func docRequire(cmd *cobra.Command, example string, names ...string) error {
missing := make([]string, 0, len(names))
for _, name := range names {
flag := cmd.Flags().Lookup(name)
if flag == nil || strings.TrimSpace(flag.Value.String()) == "" {
missing = append(missing, "--"+name)
}
}
if len(missing) == 0 {
return nil
}
return docLocalError(cmd, CodeMissingParam, "缺少必填参数: "+strings.Join(missing, ", "), "示例: "+example)
}
func docRequireNode(cmd *cobra.Command, example string) error {
if flagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") != "" {
return nil
}
return docLocalError(cmd, CodeMissingParam, "缺少目标文档 --node(可传文档 URL 或 nodeId)", "先用 dws drive search 或 dws wiki node search 获取 nodeId。示例: "+example)
}
func docValidateLocalFile(cmd *cobra.Command, flagName, example string) error {
path, _ := cmd.Flags().GetString(flagName)
if strings.TrimSpace(path) == "" {
return docRequire(cmd, example, flagName)
}
info, err := os.Stat(path)
if err != nil {
return docLocalError(cmd, CodeFileNotFound, fmt.Sprintf("本地文件 %q 不可读取", path), "检查路径后重试。示例: "+example)
}
if info.IsDir() {
return docLocalError(cmd, CodeInvalidPath, fmt.Sprintf("%q 是目录,不是文件", path), "请传入具体文件路径。示例: "+example)
}
return nil
}
func docValidateWhere(cmd *cobra.Command, example string) error {
where, _ := cmd.Flags().GetString("where")
ref, _ := cmd.Flags().GetString("ref-block")
if where != "" && where != "before" && where != "after" {
return docLocalError(cmd, CodeInvalidParam, fmt.Sprintf("--where %q 无效,仅支持 before 或 after", where), "示例: "+example)
}
if where != "" && ref == "" {
return docLocalError(cmd, CodeMissingParam, "使用 --where 时必须同时提供 --ref-block", "先用 dws doc block list --node <DOC_ID> 获取 blockId。示例: "+example)
}
return nil
}
func docValidateEnum(cmd *cobra.Command, name string, allowed []string, example string) error {
value, _ := cmd.Flags().GetString(name)
if value == "" {
return nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return nil
}
}
return docLocalError(cmd, CodeInvalidParam, fmt.Sprintf("--%s %q 无效,仅支持 %s", name, value, strings.Join(allowed, "、")), "示例: "+example)
}
func docValidateLimit(cmd *cobra.Command, max int, example string) error {
for _, name := range []string{"limit", "page-size", "max-results"} {
if flag := cmd.Flags().Lookup(name); flag != nil && cmd.Flags().Changed(name) {
value, err := cmd.Flags().GetInt(name)
if err == nil && (value < 1 || value > max) {
return docLocalError(cmd, CodeInvalidParam, fmt.Sprintf("--%s 必须在 1 到 %d 之间", name, max), "示例: "+example)
}
}
}
return nil
}
func docValidateBlockContent(cmd *cobra.Command, example string) error {
changed := make([]string, 0, 3)
for _, name := range []string{"text", "heading", "element"} {
if flag := cmd.Flags().Lookup(name); flag != nil && cmd.Flags().Changed(name) && strings.TrimSpace(flag.Value.String()) != "" {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return docLocalError(cmd, CodeMissingParam, "必须提供一种块内容:--text、--heading 或 --element", "示例: "+example)
}
if len(changed) > 1 {
return docLocalError(cmd, CodeInvalidParam, "块内容参数不能同时使用: "+strings.Join(changed, ", "), "三选一。示例: "+example)
}
format, _ := cmd.Flags().GetString("content-format")
if format == "jsonml" && changed[0] != "--element" {
return docLocalError(cmd, CodeInvalidParam, "--content-format jsonml 必须通过 --element 提供 JSONML 节点", "示例: "+example)
}
return nil
}
func docValidatePermissionUsers(cmd *cobra.Command, example string) error {
raw := flagOrFallback(cmd, "users", "user")
users := parseCommentMentionIds(raw)
if len(users) == 0 {
return docLocalError(cmd, CodeMissingParam, "缺少至少一个用户 ID:--users", "示例: "+example)
}
if len(users) > 30 {
return docLocalError(cmd, CodeInvalidParam, fmt.Sprintf("单次最多处理 30 个用户,当前为 %d 个", len(users)), "请拆分为多次调用")
}
return nil
}
func validateDocLocalArgs(cmd *cobra.Command) error {
path := strings.TrimPrefix(cmd.CommandPath(), "dws ")
switch path {
case "doc info":
return docRequireNode(cmd, "dws "+path+" --node <DOC_ID> --format json")
case "doc search":
if err := docValidateLimit(cmd, 30, "dws doc search --query \"周报\" --limit 10"); err != nil {
return err
}
createdFrom, _ := cmd.Flags().GetInt64("created-from")
createdTo, _ := cmd.Flags().GetInt64("created-to")
visitedFrom, _ := cmd.Flags().GetInt64("visited-from")
visitedTo, _ := cmd.Flags().GetInt64("visited-to")
if createdFrom < 0 || createdTo < 0 || visitedFrom < 0 || visitedTo < 0 {
return docLocalError(cmd, CodeInvalidParam, "时间过滤值必须是非负毫秒时间戳", "示例: dws doc search --created-from 1700000000000 --created-to 1710000000000")
}
if cmd.Flags().Changed("created-from") && cmd.Flags().Changed("created-to") && createdFrom > createdTo {
return docLocalError(cmd, CodeInvalidParam, "--created-from 不能晚于 --created-to", "请交换起止时间")
}
if cmd.Flags().Changed("visited-from") && cmd.Flags().Changed("visited-to") && visitedFrom > visitedTo {
return docLocalError(cmd, CodeInvalidParam, "--visited-from 不能晚于 --visited-to", "请交换起止时间")
}
case "doc list":
return docValidateLimit(cmd, 50, "dws doc list --workspace <WORKSPACE_ID> --limit 50")
case "doc read":
if err := docRequireNode(cmd, "dws doc read --node <DOC_ID> --format json"); err != nil {
return err
}
if depth, _ := cmd.Flags().GetInt("max-depth"); depth < 0 {
return docLocalError(cmd, CodeInvalidParam, "--max-depth 不能为负数", "示例: dws doc read --node <DOC_ID> --content-format jsonml --scope outline --max-depth 3")
}
if scopeValue, _ := cmd.Flags().GetString("scope"); scopeValue != "" {
valid := false
for _, candidate := range []string{"outline", "range", "section", "tags"} {
if scopeValue == candidate {
valid = true
}
}
if !valid {
return docLocalError(cmd, CodeInvalidParam, fmt.Sprintf("invalid --scope %q: must be one of outline|range|section|tags", scopeValue), "示例: dws doc read --node <DOC_ID> --content-format jsonml --scope outline")
}
}
format, _ := cmd.Flags().GetString("content-format")
scope, _ := cmd.Flags().GetString("scope")
tags, _ := cmd.Flags().GetString("tags")
startBlockID, _ := cmd.Flags().GetString("start-block-id")
if (scope == "range" || scope == "section") && strings.TrimSpace(startBlockID) == "" {
return docLocalError(cmd, CodeMissingParam, "--scope "+scope+" 必须提供 --start-block-id", "先用 dws doc block list --node <DOC_ID> --format json 获取真实 blockId")
}
if cmd.Flags().Changed("output") && format != "jsonml" {
return docLocalError(cmd, CodeInvalidParam, "--output 仅支持 --content-format jsonml", "Markdown 内容会直接显示在终端;如需保存为文件,请执行: dws doc read --node <DOC_ID> --content-format markdown --format raw > body.md")
}
if (scope != "" || tags != "") && format != "jsonml" {
return docLocalError(cmd, CodeInvalidParam, "--scope/--tags requires --content-format jsonml", "示例: dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags h1,h2")
}
if scope == "tags" && strings.TrimSpace(tags) == "" {
return docLocalError(cmd, CodeMissingParam, "--tags is required when --scope=tags", "示例: dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags h1,h2")
}
if scope != "tags" && tags != "" {
if scope == "" {
return docLocalError(cmd, CodeInvalidParam, "--tags requires --scope tags", "移除 --tags 或改用 --scope tags")
}
return docLocalError(cmd, CodeInvalidParam, "--tags only works with --scope tags", "移除 --tags 或改用 --scope tags")
}
case "doc create":
if strings.TrimSpace(flagOrFallback(cmd, "name", "title")) == "" {
return docLocalError(cmd, CodeMissingParam, "缺少文档名称 --name", "示例: dws doc create --name \"项目周报\" --format json")
}
if cmd.Flags().Changed("content") && cmd.Flags().Changed("content-file") {
return docLocalError(cmd, CodeInvalidParam, "--content 与 --content-file 不能同时使用", "短文本用 --content;长文本或表格用 --content-file")
}
if cmd.Flags().Changed("content-file") {
return docValidateLocalFile(cmd, "content-file", "dws doc create --name \"周报\" --content-file ./weekly.md")
}
case "doc update":
if err := docRequireNode(cmd, "dws doc update --node <DOC_ID> --content \"追加内容\" --mode append"); err != nil {
return err
}
if cmd.Flags().Changed("content") && cmd.Flags().Changed("content-file") {
return docLocalError(cmd, CodeInvalidParam, "--content 与 --content-file 不能同时使用", "二选一;长内容优先 --content-file")
}
if cmd.Flags().Changed("content-file") {
if err := docValidateLocalFile(cmd, "content-file", "dws doc update --node <DOC_ID> --content-file ./body.md --mode append"); err != nil {
return err
}
}
mode, _ := cmd.Flags().GetString("mode")
if mode != "append" && mode != "overwrite" {
return docLocalError(cmd, CodeInvalidParam, "--mode 必须是 append 或 overwrite", "追加优先使用: dws doc update --node <DOC_ID> --content \"内容\" --mode append")
}
if idx, _ := cmd.Flags().GetInt("index"); cmd.Flags().Changed("index") && (mode != "append" || idx < 0) {
return docLocalError(cmd, CodeInvalidParam, "--index 仅支持 mode=append 且必须大于等于 0", "示例: dws doc update --node <DOC_ID> --content \"内容\" --mode append --index 0")
}
content := flagOrFallback(cmd, "content", "markdown")
if strings.TrimSpace(content) == "" && !cmd.Flags().Changed("content-file") {
return docLocalError(cmd, CodeMissingParam, "必须通过 --content 或 --content-file 提供非空内容", "示例: dws doc update --node <DOC_ID> --content \"追加内容\" --mode append")
}
dryRun, _ := cmd.Flags().GetBool("dry-run")
yes, _ := cmd.Flags().GetBool("yes")
if dryRun && mode != "overwrite" {
return docLocalError(cmd, CodeInvalidParam, "--dry-run 仅用于预览 overwrite 覆盖写入", "append 本身不覆盖全文,请移除 --dry-run")
}
if yes && mode != "overwrite" {
return docLocalError(cmd, CodeInvalidParam, "--yes 仅用于确认 overwrite 覆盖写入", "append 无需 --yes,请移除该参数")
}
if mode == "overwrite" && !dryRun && !yes {
return docLocalError(cmd, CodeInvalidParam, "--mode overwrite 必须加 --yes 确认,或使用 --dry-run 预览", "示例: dws doc update --node <DOC_ID> --content-file ./body.md --mode overwrite --dry-run")
}
case "doc block list":
if err := docRequireNode(cmd, "dws doc block list --node <DOC_ID> --format json"); err != nil {
return err
}
start, _ := cmd.Flags().GetInt("start-index")
end, _ := cmd.Flags().GetInt("end-index")
if start < 0 || end < 0 || (cmd.Flags().Changed("end-index") && end < start) {
return docLocalError(cmd, CodeInvalidParam, "块索引必须非负,且 --end-index 不能小于 --start-index", "示例: dws doc block list --node <DOC_ID> --start-index 0 --end-index 5")
}
case "doc block insert":
if err := docRequireNode(cmd, "dws doc block insert --node <DOC_ID> --text \"内容\""); err != nil {
return err
}
if err := docValidateWhere(cmd, "dws doc block insert --node <DOC_ID> --text \"内容\" --ref-block <BLOCK_ID> --where after"); err != nil {
return err
}
if level, _ := cmd.Flags().GetInt("level"); level < 1 || level > 6 {
return docLocalError(cmd, CodeInvalidParam, "--level 必须在 1 到 6 之间", "示例: dws doc block insert --node <DOC_ID> --heading \"标题\" --level 2")
}
if idx, _ := cmd.Flags().GetInt("index"); cmd.Flags().Changed("index") && idx < 0 {
return docLocalError(cmd, CodeInvalidParam, "--index 不能为负数", "示例: dws doc block insert --node <DOC_ID> --text \"内容\" --index 0")
}
return docValidateBlockContent(cmd, "dws doc block insert --node <DOC_ID> --text \"内容\"")
case "doc block update":
if err := docRequireNode(cmd, "dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\""); err != nil {
return err
}
if err := docRequire(cmd, "dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\"", "block-id"); err != nil {
return err
}
if level, _ := cmd.Flags().GetInt("level"); level < 1 || level > 6 {
return docLocalError(cmd, CodeInvalidParam, "--level 必须在 1 到 6 之间", "示例: dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --heading \"标题\" --level 2")
}
return docValidateBlockContent(cmd, "dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\"")
case "doc block delete":
if err := docRequireNode(cmd, "dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes"); err != nil {
return err
}
return docRequire(cmd, "dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes", "block-id")
case "doc media download":
if err := docRequireNode(cmd, "dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>"); err != nil {
return err
}
return docRequire(cmd, "dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>", "resource-id")
case "doc media insert":
if err := docRequireNode(cmd, "dws doc media insert --node <DOC_ID> --file ./report.pdf"); err != nil {
return err
}
if err := docValidateWhere(cmd, "dws doc media insert --node <DOC_ID> --file ./report.pdf --ref-block <BLOCK_ID> --where after"); err != nil {
return err
}
return docValidateLocalFile(cmd, "file", "dws doc media insert --node <DOC_ID> --file ./report.pdf")
case "doc comment list":
if err := docRequireNode(cmd, "dws doc comment list --node <DOC_ID> --format json"); err != nil {
return err
}
if err := docValidateLimit(cmd, 50, "dws doc comment list --node <DOC_ID> --limit 50"); err != nil {
return err
}
if err := docValidateEnum(cmd, "type", []string{"global", "inline"}, "dws doc comment list --node <DOC_ID> --type inline"); err != nil {
return err
}
return docValidateEnum(cmd, "resolve-status", []string{"resolved", "unresolved"}, "dws doc comment list --node <DOC_ID> --resolve-status unresolved")
case "doc comment create":
if err := docRequireNode(cmd, "dws doc comment create --node <DOC_ID> --content \"评论\""); err != nil {
return err
}
return docRequire(cmd, "dws doc comment create --node <DOC_ID> --content \"评论\"", "content")
case "doc comment reply", "doc comment update":
if err := docRequireNode(cmd, "dws "+path+" --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"内容\""); err != nil {
return err
}
if err := docRequire(cmd, "dws "+path+" --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"内容\"", "comment-key", "content"); err != nil {
return err
}
if path == "doc comment reply" {
emoji, _ := cmd.Flags().GetBool("emoji")
groups, groupErr := commentGroupMentionIDs(cmd)
if groupErr != nil {
return groupErr
}
if emoji && len(groups) > 0 {
return docLocalError(cmd, CodeInvalidParam, "--emoji cannot be used with --mentioned-open-conversation-id: emoji replies do not support group mentions", "表情回复请移除群 @;需要 @群时使用普通文字回复")
}
}
case "doc comment delete":
if err := docRequireNode(cmd, "dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes"); err != nil {
return err
}
return docRequire(cmd, "dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes", "comment-key")
case "doc comment create-inline":
if err := docRequireNode(cmd, "dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 5 --content \"评论\""); err != nil {
return err
}
if err := docRequire(cmd, "dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 5 --content \"评论\"", "block-id", "content"); err != nil {
return err
}
start, _ := cmd.Flags().GetInt("start")
end, _ := cmd.Flags().GetInt("end")
if !cmd.Flags().Changed("start") || !cmd.Flags().Changed("end") || start < 0 || end <= start {
return docLocalError(cmd, CodeInvalidParam, "划词范围必须显式提供 --start/--end,且满足 0 <= start < end", "先读取块文本确认字符偏移。示例: dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 5 --content \"评论\"")
}
case "doc export":
if err := docRequireNode(cmd, "dws doc export --node <DOC_ID> --export-format pdf --output ./report.pdf"); err != nil {
return err
}
if err := docRequire(cmd, "dws doc export --node <DOC_ID> --export-format pdf --output ./report.pdf", "output"); err != nil {
return err
}
return docValidateEnum(cmd, "export-format", []string{"docx", "markdown", "md", "pdf"}, "dws doc export --node <DOC_ID> --export-format pdf --output ./report.pdf")
case "doc export get":
return docRequire(cmd, "dws doc export get --job-id <JOB_ID>", "job-id")
case "doc import":
if err := docValidateLocalFile(cmd, "file", "dws doc import --file ./report.docx --workspace <WORKSPACE_ID>"); err != nil {
return err
}
if !deps.Caller.DryRun() && flagOrFallback(cmd, "folder", "folder-id") == "" && flagOrFallback(cmd, "workspace", "workspace-id") == "" {
return docLocalError(cmd, CodeMissingParam, "导入文档必须提供 --folder 或 --workspace 作为目标位置", "先用 dws wiki space list --type myWikiSpace --format json 获取 workspaceId")
}
case "doc import get":
return docRequire(cmd, "dws doc import get --task-id <TASK_ID>", "task-id")
case "doc version save", "doc version list", "doc version revert":
if err := docRequireNode(cmd, "dws "+path+" --node <DOC_ID> --format json"); err != nil {
return err
}
if path == "doc version revert" && (!cmd.Flags().Changed("version")) {
return docLocalError(cmd, CodeMissingParam, "缺少目标版本 --version", "先用 dws doc version list --node <DOC_ID> 获取真实版本号")
}
if path == "doc version revert" {
version, _ := cmd.Flags().GetInt("version")
if version < 1 {
return docLocalError(cmd, CodeInvalidParam, "--version 必须是大于 0 的真实版本号", "先用 dws doc version list --node <DOC_ID> 获取版本号")
}
}
if path == "doc version list" {
return docValidateLimit(cmd, 50, "dws doc version list --node <DOC_ID> --limit 10")
}
case "doc permission add", "doc permission update":
if err := docRequireNode(cmd, "dws "+path+" --node <DOC_ID> --users uid1 --role EDITOR"); err != nil {
return err
}
if err := docValidatePermissionUsers(cmd, "dws "+path+" --node <DOC_ID> --users uid1 --role EDITOR"); err != nil {
return err
}
if err := docRequire(cmd, "dws "+path+" --node <DOC_ID> --users uid1 --role EDITOR", "role"); err != nil {
return err
}
return docValidateEnum(cmd, "role", []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}, "dws "+path+" --node <DOC_ID> --users uid1 --role EDITOR")
case "doc permission remove":
if err := docRequireNode(cmd, "dws doc permission remove --node <DOC_ID> --users uid1"); err != nil {
return err
}
return docValidatePermissionUsers(cmd, "dws doc permission remove --node <DOC_ID> --users uid1")
case "doc permission list":
if err := docRequireNode(cmd, "dws doc permission list --node <DOC_ID> --limit 30"); err != nil {
return err
}
if err := docValidateLimit(cmd, 200, "dws doc permission list --node <DOC_ID> --limit 30"); err != nil {
return err
}
if roles, _ := cmd.Flags().GetString("filter-role"); roles != "" {
for _, role := range parseRoleList(roles) {
valid := false
for _, candidate := range []string{"OWNER", "MANAGER", "EDITOR", "DOWNLOADER", "READER"} {
if role == candidate {
valid = true
}
}
if !valid {
return docLocalError(cmd, CodeInvalidParam, "--filter-role 包含非法角色 "+role, "仅支持 OWNER、MANAGER、EDITOR、DOWNLOADER、READER")
}
}
}
case "doc template list":
if err := docValidateEnum(cmd, "source", []string{"MY", "PUBLIC"}, "dws doc template list --source MY"); err != nil {
return err
}
return docValidateLimit(cmd, 50, "dws doc template list --limit 20")
case "doc template search":
if flagOrFallback(cmd, "query", "keyword", "name") == "" {
return docLocalError(cmd, CodeMissingParam, "缺少非空模板关键词 --query", "示例: dws doc template search --query \"周报\" --format json")
}
if err := docValidateEnum(cmd, "source", []string{"MY", "PUBLIC"}, "dws doc template search --query \"周报\" --source PUBLIC"); err != nil {
return err
}
return docValidateLimit(cmd, 50, "dws doc template search --query \"周报\" --limit 20")
case "doc template apply":
if flagOrFallback(cmd, "template-id", "template", "tpl-id") == "" {
return docLocalError(cmd, CodeMissingParam, "缺少模板 ID --template-id", "先用 dws doc template search --query \"关键词\" 获取真实 templateId")
}
}
return nil
}
func docLocalFileExtension(path string) string { return strings.ToLower(filepath.Ext(path)) }
@@ -1,137 +0,0 @@
package helpers
import (
"errors"
"strings"
"testing"
)
func TestDocLocalPreflightRejectsBeforeMCP(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"info-node", []string{"info"}, "--node"},
{"read-node", []string{"read"}, "--node"},
{"create-name", []string{"create"}, "--name"},
{"create-content-conflict", []string{"create", "--name", "x", "--content", "a", "--content-file", "b"}, "不能同时"},
{"create-file-missing", []string{"create", "--name", "x", "--content-file", "/definitely/not/found.md"}, "不可读取"},
{"update-node", []string{"update", "--content", "x", "--mode", "append"}, "--node"},
{"update-content-conflict", []string{"update", "--node", "n", "--content", "a", "--content-file", "b", "--mode", "append"}, "不能同时"},
{"update-mode-missing", []string{"update", "--node", "n", "--content", "a"}, "--mode"},
{"update-mode-invalid", []string{"update", "--node", "n", "--content", "a", "--mode", "merge"}, "append 或 overwrite"},
{"update-index-mode", []string{"update", "--node", "n", "--content", "a", "--mode", "overwrite", "--index", "1"}, "--index"},
{"block-list-node", []string{"block", "list"}, "--node"},
{"block-list-range", []string{"block", "list", "--node", "n", "--start-index", "4", "--end-index", "2"}, "不能小于"},
{"block-insert-node", []string{"block", "insert", "--text", "x"}, "--node"},
{"block-insert-where", []string{"block", "insert", "--node", "n", "--text", "x", "--where", "middle"}, "before 或 after"},
{"block-insert-ref", []string{"block", "insert", "--node", "n", "--text", "x", "--where", "before"}, "--ref-block"},
{"block-insert-level", []string{"block", "insert", "--node", "n", "--heading", "x", "--level", "7"}, "1 到 6"},
{"block-update-node", []string{"block", "update", "--block-id", "b", "--text", "x"}, "--node"},
{"block-update-id", []string{"block", "update", "--node", "n", "--text", "x"}, "--block-id"},
{"block-delete-id", []string{"block", "delete", "--node", "n"}, "--block-id"},
{"media-download-resource", []string{"media", "download", "--node", "n"}, "--resource-id"},
{"media-insert-file", []string{"media", "insert", "--node", "n"}, "--file"},
{"comment-create-content", []string{"comment", "create", "--node", "n"}, "--content"},
{"comment-reply-key", []string{"comment", "reply", "--node", "n", "--content", "x"}, "--comment-key"},
{"comment-update-content", []string{"comment", "update", "--node", "n", "--comment-key", "k"}, "--content"},
{"comment-delete-key", []string{"comment", "delete", "--node", "n"}, "--comment-key"},
{"inline-block", []string{"comment", "create-inline", "--node", "n", "--content", "x", "--start", "0", "--end", "1"}, "--block-id"},
{"inline-range", []string{"comment", "create-inline", "--node", "n", "--block-id", "b", "--content", "x", "--start", "2", "--end", "1"}, "start < end"},
{"export-output", []string{"export", "--node", "n"}, "--output"},
{"export-job", []string{"export", "get"}, "--job-id"},
{"import-file", []string{"import", "--workspace", "w"}, "--file"},
{"import-target", []string{"import", "--file", "/definitely/not/found.docx"}, "不可读取"},
{"import-task", []string{"import", "get"}, "--task-id"},
{"version-node", []string{"version", "list"}, "--node"},
{"version-number", []string{"version", "revert", "--node", "n"}, "--version"},
{"template-query", []string{"template", "search"}, "--query"},
{"template-id", []string{"template", "apply"}, "--template-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newDocCommand()
cmd.SetArgs(tc.args)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want containing %q", err, tc.want)
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("error = %#v, want validation CLIError", err)
}
})
}
}
func TestDocLocalPreflightSecondBatchRejectsBeforeMCP(t *testing.T) {
users31 := strings.TrimSuffix(strings.Repeat("u,", 31), ",")
cases := []struct {
name string
args []string
want string
}{
{"search-created-negative", []string{"search", "--created-from", "-1"}, "非负毫秒"},
{"search-created-range", []string{"search", "--created-from", "20", "--created-to", "10"}, "created-from"},
{"search-visited-negative", []string{"search", "--visited-to", "-1"}, "非负毫秒"},
{"search-visited-range", []string{"search", "--visited-from", "20", "--visited-to", "10"}, "visited-from"},
{"search-limit-zero", []string{"search", "--limit", "0"}, "1 到 30"},
{"search-limit-large", []string{"search", "--limit", "31"}, "1 到 30"},
{"list-limit-zero", []string{"list", "--limit", "0"}, "1 到 50"},
{"list-limit-large", []string{"list", "--limit", "51"}, "1 到 50"},
{"read-depth-negative", []string{"read", "--node", "n", "--max-depth", "-1"}, "不能为负"},
{"read-scope-invalid", []string{"read", "--node", "n", "--content-format", "jsonml", "--scope", "all"}, "outline"},
{"read-scope-format", []string{"read", "--node", "n", "--scope", "outline"}, "content-format jsonml"},
{"read-tags-missing", []string{"read", "--node", "n", "--content-format", "jsonml", "--scope", "tags"}, "--tags"},
{"read-tags-wrong-scope", []string{"read", "--node", "n", "--content-format", "jsonml", "--scope", "outline", "--tags", "h1"}, "--tags only works"},
{"read-range-start-missing", []string{"read", "--node", "n", "--content-format", "jsonml", "--scope", "range"}, "--start-block-id"},
{"read-section-start-missing", []string{"read", "--node", "n", "--content-format", "jsonml", "--scope", "section"}, "--start-block-id"},
{"read-markdown-output", []string{"read", "--node", "n", "--content-format", "markdown", "--output", "body.json"}, "Markdown 内容会直接显示在终端"},
{"create-whitespace-name", []string{"create", "--name", " "}, "--name"},
{"update-empty-content", []string{"update", "--node", "n", "--content", " ", "--mode", "append"}, "非空内容"},
{"update-dry-run-append", []string{"update", "--node", "n", "--content", "x", "--mode", "append", "--dry-run"}, "仅用于预览 overwrite"},
{"update-yes-append", []string{"update", "--node", "n", "--content", "x", "--mode", "append", "--yes"}, "仅用于确认 overwrite"},
{"update-overwrite-confirm", []string{"update", "--node", "n", "--content", "x", "--mode", "overwrite"}, "必须加 --yes"},
{"block-insert-content", []string{"block", "insert", "--node", "n"}, "必须提供一种块内容"},
{"block-insert-text-heading", []string{"block", "insert", "--node", "n", "--text", "x", "--heading", "h"}, "不能同时"},
{"block-insert-heading-element", []string{"block", "insert", "--node", "n", "--heading", "h", "--element", "{}"}, "不能同时"},
{"block-insert-jsonml-text", []string{"block", "insert", "--node", "n", "--content-format", "jsonml", "--text", "x"}, "必须通过 --element"},
{"block-insert-index", []string{"block", "insert", "--node", "n", "--text", "x", "--index", "-1"}, "--index 不能"},
{"block-update-content", []string{"block", "update", "--node", "n", "--block-id", "b"}, "必须提供一种块内容"},
{"block-update-text-element", []string{"block", "update", "--node", "n", "--block-id", "b", "--text", "x", "--element", "{}"}, "不能同时"},
{"block-update-level", []string{"block", "update", "--node", "n", "--block-id", "b", "--heading", "h", "--level", "0"}, "1 到 6"},
{"comment-limit-zero", []string{"comment", "list", "--node", "n", "--limit", "0"}, "1 到 50"},
{"comment-limit-large", []string{"comment", "list", "--node", "n", "--limit", "51"}, "1 到 50"},
{"comment-type", []string{"comment", "list", "--node", "n", "--type", "all"}, "global"},
{"comment-status", []string{"comment", "list", "--node", "n", "--resolve-status", "open"}, "resolved"},
{"reply-emoji-group", []string{"comment", "reply", "--node", "n", "--comment-key", "k", "--content", "x", "--emoji", "--mentioned-open-conversation-id", "g"}, "emoji replies do not support group mentions"},
{"permission-users-missing", []string{"permission", "add", "--node", "n", "--role", "READER"}, "--users"},
{"permission-users-large", []string{"permission", "add", "--node", "n", "--role", "READER", "--users", users31}, "最多处理 30"},
{"permission-role", []string{"permission", "add", "--node", "n", "--role", "OWNER", "--users", "u"}, "MANAGER"},
{"permission-filter-role", []string{"permission", "list", "--node", "n", "--filter-role", "ADMIN"}, "非法角色"},
{"export-format", []string{"export", "--node", "n", "--output", "x", "--export-format", "html"}, "docx"},
}
if len(cases) != 39 {
t.Fatalf("second batch has %d cases, want 39", len(cases))
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newDocCommand()
cmd.SetArgs(tc.args)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want containing %q", err, tc.want)
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
t.Fatalf("error = %#v, want validation CLIError", err)
}
})
}
}
@@ -1,27 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestMultiWikiSkillDoesNotRequireYesForAppend(t *testing.T) {
path := filepath.Join("..", "..", "skills", "multi", "dingtalk-wiki", "SKILL.md")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wiki skill: %v", err)
}
text := string(content)
if strings.Contains(text, "--mode overwrite|append --content-file <tmp.md> --yes") {
t.Fatal("wiki skill still applies --yes to append and overwrite indiscriminately")
}
if !strings.Contains(text, "--mode append --content-file <tmp.md>") ||
!strings.Contains(text, "--mode overwrite --content-file <tmp.md> --yes") {
t.Fatal("wiki skill must publish separate append and confirmed overwrite examples")
}
}
-1
View File
@@ -326,7 +326,6 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
documentType, _ := result["documentType"].(string)
finalResult := map[string]any{
"success": true,
"status": "completed",
"taskId": taskID,
"documentUrl": documentURL,
"documentName": documentName,
+1 -1
View File
@@ -181,7 +181,7 @@ func TestCrossPlatformCoverageSheetImportRunsSharedDocImportFlow(t *testing.T) {
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("sheet import stdout must be one JSON document: %v\n%s", err, output)
}
if payload["nodeId"] != "node-1" || payload["success"] != true || payload["status"] != "completed" {
if payload["nodeId"] != "node-1" || payload["success"] != true {
t.Fatalf("output missing success contract: %#v", payload)
}
}
+1 -43
View File
@@ -18,8 +18,6 @@ import (
"strconv"
"strings"
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/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -84,18 +82,6 @@ var ChatMembersGet = shortcut.Shortcut{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}},
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Validate: func(rt *shortcut.RuntimeContext) error {
for _, value := range rt.StrSliceFirst("users", "open-dingtalk-ids") {
value = strings.TrimSpace(value)
if value == "" || !isOpenID(value) {
return apperrors.NewValidation(fmt.Sprintf(
"--users/--open-dingtalk-ids 只接受成员 openDingTalkId;%q 不是 openDingTalkId。请先查询人员并传返回的 openDingTalkId",
value,
))
}
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id")
return rt.CallMCP("list_group_member_by_ids", map[string]any{
@@ -200,12 +186,6 @@ var ChatUpdateIcon = shortcut.Shortcut{
{Name: "icon-media-id", Type: shortcut.FlagString, Desc: "群头像 mediaId(以 @ 开头)", Required: true},
},
Tips: []string{`dws chat +chat-update-icon --group <openConversationId> --icon-media-id <mediaId>`},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := helpers.ValidateChatMediaID(rt.Str("icon-media-id")); err != nil {
return apperrors.NewValidation("invalid --icon-media-id: " + err.Error())
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_group_icon", map[string]any{
"openConversationId": rt.Str("group"),
@@ -227,8 +207,7 @@ var ChatUpdateSettings = shortcut.Shortcut{
{Name: "setting-key", Type: shortcut.FlagString, Desc: "群设置项 key,如 searchable / onlyAdminCanAtAll", Required: true},
{Name: "status", Type: shortcut.FlagInt, Desc: "设置值:0=关闭,1=开启", Required: true},
},
Tips: []string{`dws chat +chat-update-settings --group <openConversationId> --setting-key searchable --status 1`},
Validate: validateChatUpdateSettings,
Tips: []string{`dws chat +chat-update-settings --group <openConversationId> --setting-key searchable --status 1`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_group_settings", map[string]any{
"openConversationId": rt.Str("group"),
@@ -238,27 +217,6 @@ var ChatUpdateSettings = shortcut.Shortcut{
},
}
var supportedChatSettingKeys = map[string]struct{}{
"authority": {}, "joinValidation": {}, "onlyAdminCanAtAll": {}, "searchable": {},
"addFriendForbidden": {}, "toolbarStatus": {}, "pluginCustomizeVerify": {},
"onlyAdminCanDING": {}, "allMembersCanCreateMcsConf": {}, "onlyAdminCanSetMsgTop": {},
"onlyAdminCanPinMsg": {}, "onlyAdminCanSendFile": {}, "allMembersCanCreateCalendar": {},
"groupEmailDisabled": {}, "groupRedEnvelopeSwitch": {}, "groupLiveAuthority": {},
"groupBillAuthority": {},
}
func validateChatUpdateSettings(rt *shortcut.RuntimeContext) error {
key := strings.TrimSpace(rt.Str("setting-key"))
if _, ok := supportedChatSettingKeys[key]; !ok {
return apperrors.NewValidation(fmt.Sprintf("不支持的 --setting-key %q;请使用 chat group update-settings --help 中列出的设置项", key))
}
status := rt.Int("status")
if status != 0 && status != 1 {
return apperrors.NewValidation("--status 只允许 0(关闭)或 1(开启)")
}
return nil
}
// ChatDismiss dismisses (destroys) a group (dismiss_group, im).
var ChatDismiss = shortcut.Shortcut{
Service: "chat",
-38
View File
@@ -54,15 +54,6 @@ func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
}
}
func TestMessagesSendUploadTargetUsesUploadInterfaceFields(t *testing.T) {
if got := messagesSendUploadTarget("cid-1", ""); !reflect.DeepEqual(got, map[string]any{"openConversationId": "cid-1"}) {
t.Fatalf("group upload target = %#v", got)
}
if got := messagesSendUploadTarget("", "D-open-1"); !reflect.DeepEqual(got, map[string]any{"openDingTalkId": "D-open-1"}) {
t.Fatalf("direct upload target = %#v", got)
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
@@ -164,9 +155,6 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
fake.calls[2].tool != "send_personal_message" {
t.Fatalf("file flow calls = %#v", fake.calls)
}
if fake.calls[0].args["openConversationId"] != "cid" || fake.calls[1].args["openConversationId"] != "cid" {
t.Fatalf("upload target args = %#v / %#v, want openConversationId", fake.calls[0].args, fake.calls[1].args)
}
send := fake.calls[2]
if send.args["msgType"] != "file" || send.args["openConversationId"] != "cid" ||
send.args["uuid"] != "file-key" {
@@ -189,32 +177,6 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
}
}
func TestChatShortcutInvalidInputsStopBeforeMCP(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "member numeric user id", args: []string{"chat", "+chat-members-get", "--id", "cid", "--users", "489149"}},
{name: "icon local path", args: []string{"chat", "+chat-update-icon", "--group", "cid", "--icon-media-id", "./logo.png", "--yes"}},
{name: "setting unknown key", args: []string{"chat", "+chat-update-settings", "--group", "cid", "--setting-key", "unknown", "--status", "1", "--yes"}},
{name: "setting invalid status", args: []string{"chat", "+chat-update-settings", "--group", "cid", "--setting-key", "searchable", "--status", "2", "--yes"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(tc.args)
if err := root.Execute(); err == nil {
t.Fatal("invalid command succeeded, want validation error")
}
if len(fake.calls) != 0 {
t.Fatalf("MCP calls = %#v, want zero", fake.calls)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
+2 -8
View File
@@ -420,7 +420,8 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
targetArgs := messagesSendUploadTarget(group, openID)
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
@@ -489,13 +490,6 @@ func executeMessagesSendUserFile(
})
}
func messagesSendUploadTarget(group, openID string) map[string]any {
if group != "" {
return map[string]any{"openConversationId": group}
}
return map[string]any{"openDingTalkId": openID}
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
@@ -2,11 +2,12 @@
> 通用规范见 [_common/conventions.md](_common/conventions.md)。
## 显式工作流
## 显式工作流与事实保真
- 用户点名的 `create → list → insert/append/update` 是可观察命令链,必须保持顺序逐项执行;create 只承载明确的初始正文。有序列表块必须验证回读结构中的 `list.isOrdered=true`。
- `--name` 不替代用户显式要求的正文 H1;新建资源返回 ID 后,同一请求的指代绑定该新资源,禁止搜索同名旧资源替换。
- Word/Excel 需要“在线编辑/直接在线改”时使用 `doc import`,普通 `drive upload` 只保留原文件。
- 汇总时保留证据强度:验证数量不等于通过数量,整理问题不等于根因分析。任一步骤返回 `null`/空结果或回查不一致时只能报告部分完成。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
+2
View File
@@ -1060,6 +1060,8 @@ EOF
- `drive upload` / `doc upload` 是普通文件存储路径;用户要求 Word/Excel “在线编辑/直接在线改”时硬路由到 `doc import`,并验证导入后的在线类型和文件夹。只有用户明确同时要原文件与在线版时才分别 upload + import
- 同一请求中新建、复制或导入返回的 `nodeId` 必须绑定后续“这篇/刚才那篇/上次那篇”;禁止搜索同名旧资源覆盖绑定
- `--name` 只是文档外壳标题,不能替代用户显式要求的正文 H1;用户说“正文先起一级标题”时必须写入或插入真实 H1
- 汇总只能保持用户事实强度:“验证 12 条”不等于“12 条全部通过”,“整理问题清单”不等于“输出根因分析”
- 写操作响应为 `null`/空对象或回查未变化时,该步骤失败;必须报告部分完成,禁止用其他成功步骤把整体说成“全部完成”
- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作
- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录
- `media insert` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 插入附件块到文档),无需手动分步操作
@@ -128,6 +128,7 @@ Flags:
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `reply --emoji` 不能同时 @群。
- `comment create/reply/update/delete` 的退出码 0 不等于业务成功。响应为 `null`、空对象或缺少可核验字段时,立即执行 `comment list` 回查目标 `commentKey`。若 update 后正文仍是旧值,必须判定“更新未生效”;即使其他步骤成功或评论随后被删除,也只能报告部分完成,禁止写“全部完成”。
## 上下文传递
+5 -1
View File
@@ -60,6 +60,8 @@ def run_dws(args: Sequence[str], dry_run: bool = False) -> Any:
f"dws 命令失败:{detail or f'退出码 {result.returncode}'}"
)
data = decode_json_output(result.stdout)
if data is None or data == {}:
raise ScriptError("dws 返回空业务结果,无法确认操作成功")
if isinstance(data, dict) and data.get("success") is False:
detail = data.get("errorMsg") or data.get("message") or "未知错误"
raise ScriptError(f"dws 业务调用失败:{detail}")
@@ -145,12 +147,14 @@ def run(argv: Optional[Sequence[str]] = None) -> int:
["doc", "info", "--node", node_id, "--format", "json"],
dry_run=args.dry_run,
)
run_dws(
readback = run_dws(
["doc", "read", "--node", node_id, "--format", "json"],
dry_run=args.dry_run,
)
if args.dry_run:
return 0
if not first_value(readback, ("markdown", "jsonml", "content")):
raise ScriptError("文档回读未返回正文,无法确认写入成功")
summary = {
"success": True,
+30 -49
View File
@@ -11,24 +11,25 @@ metadata:
# 钉钉 AI 表格 Skill
## 执行入口
## 前置条件 — 执行操作前必读
执行任何 `dws` 操作前,完整读取 [`dws-shared`](../dws-shared/SKILL.md),但不要预加载其 references。高频意图直接使用本文件骨架;仅特殊参数、复杂数据形态或边界不明时读取一个 branch reference。
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
## 加载与路由顺序
1. 命中下方高频意图时直接使用精确骨架,不先查 Help 或产品级 Schema。
2. 路由优先级固定为:精确骨架 / recipe > 匹配的公开 Shortcut > 原子命令。脚本只用于 Runtime 尚未覆盖的批量、文件传输或异步编排,不与普通原子命令竞争默认入口。
2. 路由优先级固定为:精确 recipe / 可运行脚本 > 匹配的公开 Shortcut > 原子命令。命令已确定且参数清楚时直接执行。
3. 参数、约束或安全语义不确定时只读 leaf Schema:`dws schema --cli-path "aitable <leaf>" --format json`;只有当前 Cobra flag 不确定时才读对应 `--help`。
4. 复杂字段、筛选、导入导出、视图、权限或工作流任务,按“低频能力与 Reference”只加载相关文件,不预读整个 `references/aitable/`。
4. 复杂任务按下表只读相关 Reference;路径相对本 Skill 根目录且必须保留 `aitable/`。脚本型任务只读 `references/aitable/aitable-script-recipes.md`,再运行 `scripts/aitable_ops.py`;不预读目录或源码。
5. 现有骨架和 reference 都无法定位能力时,才用 Runtime Shortcut Catalog 做最后发现;不得猜 `cli_path` 或 flag。
6. Schema、Help、reference 与实际返回冲突时采用更安全的解释并报告契约漂移;`confirmation=user_required` 时先确认,再添加 `--yes`。
7. 用户已给足目标、字段和数据时,按依赖链连续执行;中间结果只用于提取真实 ID 和判断停止条件,全部完成后统一回读并答复。
7. 输入完整时直接完成依赖链,不调用 todo、不分步汇报或重复追问;中间响应只提取 ID/错误,最后统一回读答复。脚本参数明确就执行;不明确才读操作级 `--help`,仅契约失败、环境异常或用户要求修改时读源码。
8. 用户要求新建 Base 但未指定 Base 名时,根据业务目标生成简短描述性名称(例如仪表盘任务用“数据看板”)并继续;不要仅为可回退的容器名称追问。Base 只接受 Base flags,不得把 table `--fields` 传给 `base create`。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
`aitable` 当前有 29 条公开 shortcut,完整清单保留在 Runtime Shortcut Catalog,根 Skill 不重复展开。
29 条公开 shortcut 保留在 Runtime Catalog,不在根 Skill 展开。已知意图直接走下方路由;仅参数/安全语义不确定时读 leaf Schema,Cobra flags 不确定时读 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
@@ -44,9 +45,7 @@ metadata:
| View / Dashboard / Chart | `viewId` / `dashboardId` / `chartId` 各自绑定当前 Base/Table,不跨对象复用 |
| 异步任务 | `taskId` / `importId` 只用于对应导出或导入任务,不能替代业务对象 ID |
所有下游 ID 都从当前链路的结构化返回中提取;同名多候选必须让用户消歧,不默认取第一项。Base → Table → Field/Record/View 的容器关系必须保持一致,不跨 Base 或 Table 复用子对象 ID。
创建、复制、导入或新建字段/记录返回 ID 后,立即绑定同一请求中的“这个”“刚才新建的”等指代;除非用户明确转向历史资源,否则不得再按名称搜索并替换为旧对象。
所有下游 ID 都从当前链路的结构化返回中提取;同名多候选必须让用户消歧,不默认取第一项,也不复用未经本轮校验的旧 ID。
## 核心意图与执行骨架
@@ -54,67 +53,49 @@ metadata:
|---|---|---|
| 按名称找 Base | `dws aitable +resolve-base --name "<名称>" --format json` | 唯一命中才继续;多候选停止并消歧 |
| 浏览最近访问 | `dws aitable +base-list --format json` | 只代表最近访问,不得宣称全量 |
| 搜索模板 | `dws aitable template search --query "<关键词>" --format json` | 只返回真实候选,不擅自套用模板或创建 Base |
| 搜索模板 | `dws aitable +template-search --query "<关键词>" --format json` | 关键词参数是 `--query`,只返回真实候选,不擅自创建 Base |
| 按名称找 Table | `dws aitable +resolve-table --base <baseId> --name "<表名>" --format json` | `baseId` 必须来自上一步真实返回 |
| 取表、字段与视图目录 | `dws aitable +table-get --base-id <baseId> [--table-ids <tableId>] --format json` | `tables[].fields[]` 是字段目录;完整类型/config 再用 `+field-get` |
| 取字段完整配置 | `dws aitable +field-get --base-id <baseId> --table-id <tableId> [--field-ids <ids>] --format json` | 写入前核对类型、只读性和 select options;按需展开以控制返回体 |
| 查/搜/筛记录 | `dws aitable +record-query --base-id <baseId> --table-id <tableId> [--query <词>\|--filters '<JSON>'\|--record-ids <ids>] --format json` | ID 模式忽略 filter/sort;不要猜 `--page-limit`,分页使用返回的 cursor 与 leaf Help 中的真实分页 flag |
| 查/搜/筛记录 | `dws aitable +record-query --base-id <baseId> --table-id <tableId> [--query <词>\|--filters '<JSON>'\|--record-ids <ids>] --format json` | ID 模式忽略 filter/sort;全量结论必须完整分页 |
| 新增记录 | `dws aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":<值>}}]' --format json` | 单次最多 100;取 `data.newRecordIds[]` 后立即按 ID 回读 |
| 更新记录 | `dws aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<id>","cells":{"<fieldId>":<值>}}]' --format json` | 先 query 拿 recordId;只传需改字段;取 `data.recordIds[]` 后回读 |
| 删除记录 | 先 `dws aitable +record-query ...` 定位,再 `dws aitable record delete --base-id <baseId> --table-id <tableId> --record-ids <ids>` | 展示目标与影响,得到明确确认后才加 `--yes` |
| 创建 Base / Table | `dws aitable base create --name "<名>"` / `dws aitable table create --base-id <id> --name "<名>" --fields '[...]'` | 使用创建返回的真实 ID;系统改名/加后缀时不得继续猜原名 |
| 复制视图 | `dws aitable view duplicate --base-id <baseId> --table-id <tableId> --view-id <viewId> --new-name "<名>"` | `viewId` 必须属于当前表;不能用复制 Table 或新建 Dashboard 替代 |
| 创建图表 | 先读 `dws aitable chart config-example --format json`,再执行 `chart create ... --config '<JSON>' --layout '<JSON>'` | `--layout` 是必填项;不能只传 config 后依据退出码声称图表已创建 |
| 批量追加 CSV / JSON 到已有表 | `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]` | CSV 表头必须是 fieldId;脚本返回不完整 ledger 时不得宣称全成功 |
| 文件导入为新数据表 | `python3 scripts/aitable_import_via_task.py <baseId> <file>` | 与“追加已有 table”不同;走 prepare → PUT → import task |
| 批量创建字段 | `python3 scripts/bulk_add_fields.py <baseId> <tableId> fields.json` | 单次最多 15;逐项检查成功/失败结果 |
| 导出 Base / Table / View | `python3 scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view [...]` | 保存路径、覆盖与异步未完成状态必须显式处理 |
| 上传记录附件 | `python3 scripts/upload_attachment.py <baseId> <file>` | 返回 `fileToken` 后仍需按字段格式写入记录并回读 |
| 创建 Base / Table | `dws aitable base create --name "<名>" --format json` → `dws aitable base get --base-id <baseId> --format json`;`dws aitable table create --base-id <id> --name "<名>" --fields '[...]' --format json` → `dws aitable +table-get --base-id <id> --table-ids <tableId> --format json` | 使用创建返回的真实 ID 立即回读;创建字段时回读 `fields[]` 的名称、类型与 config;系统改名/加后缀时不得继续猜原名 |
| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<仪表盘名>" [--chart-specs-file <workspace内JSON文件>]` | 唯一首选;完整参数与 ledger 契约只读 `references/aitable/aitable-script-recipes.md` |
| 复制视图 | `dws aitable view duplicate --base-id <baseId> --table-id <tableId> --view-id <源viewId> --new-name "<新名称>" --format json` | 源 viewId 来自当前表的真实返回;不要复制数据表或创建仪表盘替代 |
| 导入 / 导出 / 批量字段 / 附件 | 先读 `references/aitable/aitable-script-recipes.md`,再运行其中唯一的 `scripts/aitable_ops.py <operation> ...` | 不直接选择底层脚本;不读源码;保留统一入口返回的退出状态与 ledger |
导出完成以统一 ledger 中的真实 `taskId`、`polledTimes`、`status=success`、`fileSize>0` 和 `savedPath` 为证据;不要自己重新轮询、读脚本源码,也不要只用 `ls` 替代异步任务证据。字段类型使用 Runtime camelCase,例如 `singleSelect` / `multipleSelect`;select 写值优先传选项名字符串或 `{id,name}`,不传 `{value:...}`。
## 记录读写不变量
- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读 [aitable-cell-value.md](references/aitable/aitable-cell-value.md)。
- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读 [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)。
- 记录分页不能凭经验拼 `--page-limit`。先用当前 leaf Help 确认 page-size/cursor 的真实名称;每页读取返回 cursor,直到明确终止。分页中断或局部富化失败时保留已有结果,输出 completeness 与逐项失败 ledger,不把部分结果描述为全量。
- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读精确路径 `references/aitable/aitable-cell-value.md`。
- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读精确路径 `references/aitable/aitable-filter-sort.md`。
- **Complete evidence:** if an exact total/set relies on paginated output, use `--all --page-limit 0` or iterate until `nextCursor` is empty and `hasMore` is false. A complete write-result ID list is sufficient; a truncated page is not.
- **Capability proof:** with write authorization, create minimal prerequisites, execute, and read back. Help/Schema alone is unverified.
- 创建、更新、导入、批量建字段等写操作必须检查业务 `status`、逐项结果与返回 ID;普通写入按用户明确要求执行后回读,不能只凭退出码宣称成功。
- 长 JSON 使用 `--records-file` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。
- `table create --fields` 的键固定为 `fieldName` / `type` / `config`,不能写成 `name`;单选/多选类型固定为 `singleSelect` / `multipleSelect`,不能写 `select`。number formatter 不确定时先读 leaf Help,禁止猜 `INTEGER` 等值。
## 写入计划与验证
- 用户明确列出的“先创建、再加字段、然后写记录/建视图”等阶段是可观察的验收步骤,必须逐项真实执行;不能为了得到相似终态而折叠、重排或省略。
- 删除 Base/Table/Field/Record、关闭高级权限、删除角色和其他高风险动作,先固化目标 ID、所属容器、影响数量、副作用与可恢复性;确认前写调用为零。
- 批量导入、建字段和记录写入在第一笔写入前完成全部字段类型、只读性、关联表和文件边界校验;部分失败保留输入顺序与逐项 ledger。
| 写入对象 | 成功后必须验证 |
|---|---|
| Base / Table | 使用创建返回 ID 查询对象及所属关系 |
| Field | `+field-get` 核对 fieldId、type、config 与只读性 |
| Record | 使用返回 recordId 按 ID 回读目标 cells |
| View / Dashboard / Chart | 重新读取当前 Base/Table 下的对象配置 |
| 导入 / 导出任务 | 核对 task 状态、结果对象或输出文件完整性 |
退出码 0、`status=success`、空对象或仅有 taskId 都不能单独证明业务完成。字段未生效、回读不一致、分页不完整或异步任务未完成时,报告失败、部分完成或进行中,不得声称“全部完成”。
## 低频能力与 Reference
| 场景 | 按需读取 |
|---|---|
| 完整命令索引、对象 URL 与一级路由 | [aitable.md](references/aitable.md) |
| 记录 query/create/update/delete/upsert/history/share | 对应 `references/aitable/aitable-record-*.md` |
| 字段创建、字段 config、cellValue、公式与跨表引用 | [aitable-field.md](references/aitable/aitable-field.md)、[aitable-field-properties.md](references/aitable/aitable-field-properties.md)、[aitable-cell-value.md](references/aitable/aitable-cell-value.md)、[aitable-formula-guide.md](references/aitable/aitable-formula-guide.md) |
| 筛选、排序、统计、全量分析 | [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)、[aitable-data-analysis-sop.md](references/aitable/aitable-data-analysis-sop.md) |
| 导入导出、附件 | [aitable-export-import.md](references/aitable/aitable-export-import.md)、[aitable-attachment.md](references/aitable/aitable-attachment.md) |
| 视图、表单、仪表盘与图表 | [aitable-view-config.md](references/aitable/aitable-view-config.md)、[aitable-view-extras.md](references/aitable/aitable-view-extras.md)、[aitable-form.md](references/aitable/aitable-form.md)、[aitable-dashboard-chart.md](references/aitable/aitable-dashboard-chart.md) |
| 高级权限、自动化工作流、导航节点 | [aitable-advperm.md](references/aitable/aitable-advperm.md)、[aitable-workflow.md](references/aitable/aitable-workflow.md)、[aitable.md](references/aitable.md) 的 section 路由 |
| 完整命令索引、对象 URL 与一级路由 | `references/aitable.md` |
| 记录 query/create/update/delete | `references/aitable/aitable-record-query.md`、`references/aitable/aitable-record-create.md`、`references/aitable/aitable-record-update.md`、`references/aitable/aitable-record-delete.md` 中只读与当前动词一致的一份 |
| 记录 upsert/history/share | `references/aitable/aitable-record-upsert.md`、`references/aitable/aitable-record-history.md`、`references/aitable/aitable-record-share.md` 中只读与当前动词一致的一份 |
| 字段创建、字段 config、cellValue、公式与跨表引用 | `references/aitable/aitable-field.md`、`references/aitable/aitable-field-properties.md`、`references/aitable/aitable-cell-value.md`、`references/aitable/aitable-formula-guide.md` |
| 筛选、排序、统计、全量分析 | `references/aitable/aitable-filter-sort.md`、`references/aitable/aitable-data-analysis-sop.md` |
| dashboard/chart、导入导出、批量字段、附件脚本 | `references/aitable/aitable-script-recipes.md`(精确路径;只读这一份脚本契约) |
| 视图、表单及高级 dashboard/chart 原子回退 | `references/aitable/aitable-view-config.md`、`references/aitable/aitable-view-extras.md`、`references/aitable/aitable-form.md`、`references/aitable/aitable-dashboard-chart.md` |
| 高级权限、自动化工作流、导航节点 | `references/aitable/aitable-advperm.md`、`references/aitable/aitable-workflow.md`、`references/aitable.md` 的 section 路由 |
## 错误恢复
- 路径或 flag 错误:按既定的 leaf Schema → leaf Help 顺序校正一次;仍失败则停止,不连续尝试猜测别名。
- 命令非零、输出非 JSON、业务 `status != success`、必需 ID 缺失、批处理部分失败均视为失败;保留成功项与 ledger,禁止吞错。
- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读 [aitable-error-recovery.md](references/aitable/aitable-error-recovery.md)。
- 已确认远端未写入且契约声明可重试时,才从最新实际输出重新提取 ID 后重试;写入状态未知、删除和其他 `confirmation=user_required` 操作不得自动重试或静默确认。
- 参数校验错误必须先吸收错误中的精确 hint,再最多纠正一次;不要重复猜同一 flag、formatter、folderId 或图表配置。目标能力已有原生命令时,不要以手工重建相似对象掩盖原命令失败。
- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读精确路径 `references/aitable/aitable-error-recovery.md`。
- 每次重试都从最新实际输出重新提取下游 ID;删除和其他 `confirmation=user_required` 操作不得自动重试或静默确认。
## 跨产品协作
@@ -1,13 +1,11 @@
# 数据分析
> 定位:仅在用户要求跨记录统计、聚合或业务结论时加载。普通查找、筛选和分页先按根 Skill 的 `+record-query` 骨架执行;完整分析契约以 [aitable-data-analysis-sop.md](aitable/aitable-data-analysis-sop.md) 为准,本页只保留轻量入口。
> 本场景所有 recipe 均为 full。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. `aitable record query --base-id <baseId> --table-id <tableId>` → 取记录(分页)<br>  需要筛选时 `--filters` 格式见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md),根节点必须是 `{"operator":"and\|or","operands":[...]}`<br>4. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **批量导入优先**:`python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(自动分批创建)<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| create-aitable-record | **批量导入优先**:`python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> data.csv\|data.json [--batch-size N]`<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable record query --base-id <baseId> --table-id <tableId>` → 取 `recordId`,**先展示让用户确认**<br>3. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'` |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
@@ -1,7 +1,5 @@
# 业务域通用规范
> 定位:供 AITable 主题 Reference 复用的批量、并行采集和 ID 传递约定,不是用户意图路由入口。根 Skill 已明确的高频任务不需要单独加载本页。
> 仅服务本 skill 已迁入的行动指南。安全门控、危险操作确认、`--format json` 等已在本 skill 的 `SKILL.md` 中定义,此处不重复。
## 批量查询规范
@@ -1,7 +1,5 @@
# 记录操作详细指南
> 定位:兼容性的记录操作总览。新任务优先按需读取 `aitable/aitable-record-{query,create,update,delete,upsert}.md` 中唯一对应的叶级 Reference;不要同时加载本页和全部叶级文件。本页不得作为另一套命令发现源。
## 查询记录
```bash
@@ -104,11 +102,11 @@ dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> \
> **不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。
使用 `upload_attachment.py` 脚本(内部自动完成 prepare + PUT to OSS),**2 步**完成:
使用统一 `aitable_ops.py upload-attachment` 入口(内部委派 prepare + PUT to OSS),**2 步**完成:
```bash
# 步骤 1: 一键上传文件
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段
@@ -1,15 +1,8 @@
# AI表格 (aitable) 命令参考
> 定位:低频 atomic 能力的一级索引,不是每次调用必读的产品手册。已知高频意图直接回到 [`SKILL.md`](../SKILL.md);参数和安全语义查 leaf Schema,真实 flags 查 leaf Help;本页只在根路由无法定位能力时用于选择一个主题 Reference,禁止递归加载全部链接。
> **渐进式文档**:本文件为路由层(索引 + 意图判断),各命令的详细参数、示例和踩坑说明在 [aitable/](./aitable/) 目录下按需加载。
## 使用本索引
1. 先按对象选择一个分支:Base/Table、Field、Record、View/Form、Dashboard/Chart、Workflow、Advperm、Import/Export 或 Section。
2. 只打开该分支链接的一个主题文件;命令已确定后停止发现。
3. 所有子对象 ID 必须来自当前 Base/Table 链路;写操作完成后按对象回读。
4. 本页示例是导航证据,不覆盖 Runtime/Cobra、leaf Schema 或根 Skill 的 Golden Route。
各命令的详细参数、示例和边界在 [aitable/](./aitable/) 目录下按需加载。
已知高频意图优先使用根 Skill 的精确 Shortcut/脚本骨架;本文件只在需要完整一级命令索引、对象 URL 或低频分支导航时加载。参数与安全不确定时读 leaf Schema,Cobra flag 不确定时才读 leaf Help,不要把本文件当作参数事实源。
## 文档地址 (URI)
@@ -338,3 +331,158 @@ dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 300
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
用户说"表格/多维表/AI表格":
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
- 查看 → `field get`
- 添加 → `field create`(读 [aitable-field.md](./aitable/aitable-field.md))
- 修改 → `field update`
- 删除 → `field delete`
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
用户说"公式/formula/计算字段/派生指标" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 新建自动化 → 按子文档的最小 Demo 组装完整 DSL,再 `workflow create --dsl @file`
- 修改自动化 → `workflow get` 留底,按最新 DSL 文档生成完整目标 DSL,再 `workflow update --dsl @file`
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- 删除流程:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
## 核心工作流
```bash
# 1. 按名称解析唯一 Base — 提取 baseId;多候选必须消歧
dws aitable +resolve-base --name "项目" --format json
# 2. 按名称解析唯一 Table — 提取 tableId
dws aitable +resolve-table --base <BASE_ID> --name "任务" --format json
# 3. 获取字段目录;需要完整类型 config 时再调用 field get
dws aitable +table-get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
dws aitable +field-get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 5. 新增记录 (cells 用 fieldId 作 key)
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldXXX":"值"}}]' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `tables[].tableId`、精简 `fields[]`、`views[]` | 定位数据表、字段目录和视图;完整字段 config 再用 `field get` |
| `field get` | `fields[].fieldId/type/config` | record 操作的 cells key、类型校验、field update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## URL → baseId 提取
用户提供 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 链接时:
1. 提取 `/nodes/` 后的路径段作为 `baseId`
2. 去掉尾部的查询参数(`?` 及其后内容)
3. 传入 `--base-id` 参数
> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用;
> 如果是用户直接提供的原始 `alidocs` URL,则先按 [链接规范](url-patterns.md#alidocs-url-类型探测流程) probe,确认 `extension=able` 后再继续。
## 注意事项
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
- cells 写入/读取格式见 [aitable-cell-value.md](./aitable/aitable-cell-value.md)
- 最佳实践见 [aitable-best-practices.md](./aitable/aitable-best-practices.md)
## 自动化脚本
| 脚本 | 场景 |
|------|------|
| `scripts/aitable_ops.py` | dashboard/chart、导入、导出、批量字段与附件的唯一稳定入口;参数见 `references/aitable/aitable-script-recipes.md` |
## 相关产品
- [doc](../../dingtalk-doc/references/doc.md) — 富文本文档编辑,不是结构化数据表格
@@ -1,7 +1,5 @@
# advperm — 高级权限管理
> 加载边界:仅在开启/关闭高级权限或管理自定义角色时读取。普通协作者、文件权限或成员管理不走本页。`baseId` 和 `roleId` 必须来自当前链路;disable、role-delete 等需确认的动作先读现状、固化影响,再执行并回读。
控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
@@ -1,7 +1,5 @@
# attachment — 附件上传
> 加载边界:仅在文件要写入 AITable 的 attachment 字段时读取;普通钉盘上传走 Drive。流程必须完成“申请凭证 → HTTPS PUT → 用 fileToken 写记录 → 按 recordId 回读”,拿到 fileToken 不等于附件已进入记录。
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
>
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
@@ -25,7 +23,7 @@ Flags:
```bash
# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入
@@ -1,7 +1,5 @@
# AI 表格最佳实践
> 定位:跨主题的不变量摘要,用于复杂任务复核,不作为命令索引。若根 Skill 已给出明确骨架,不要为了普通读写预加载本页;具体字段值、过滤或错误恢复分别读取对应专题。
## 1. 字段可写性分类
| 字段类型 | 可写 | 正确方式 |
@@ -1,7 +1,5 @@
# cells 写入/读取格式规范(cellValue 数据结构)
> 加载边界:仅在已经选定 record create/update/upsert 且需要构造某种字段值时读取。先用 field get 确认 `fieldId`、`type`、`config` 和可写性;本页不负责找 Base/Table/Record,也不能把字段名当 fieldId。
> 适用命令:`dws aitable record create --records`、`dws aitable record update --records`、`dws aitable record query` 返回
>
> 本文件是 DWS AI 表格 cellValue 的 **source of truth**。写入记录时,必须严格按此格式构造 cells 对象。
@@ -1,20 +1,43 @@
# dashboard & chart — 仪表盘与图表
> 加载边界:仅在用户明确操作仪表盘或图表时读取。先用当前 Base 的 dashboard list/get 获取 `dashboardId`,再获取其 `chartId`;不要用 View 或 Table 命令替代,也不要跨 Dashboard 复用 chartId。
## 建议操作顺序
## 创建首选流程
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 仅建仪表盘
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>"
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
# 建仪表盘和常用图表
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>" \
--chart-specs-file <workspace内/charts.json>
```
## 要点
统一入口的 dashboard 操作是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选
recipe,并输出 `dws-skill-script-ledger/v1`。不要在脚本前调用 config-example 或
widgets-example,也不要在成功后重复创建或回读。
`charts.json` 是 1–6 项数组,每项参数:
| 参数 | 要求 |
|---|---|
| `name` | 必填,图表名 |
| `chart_type` | 必填:`AREA`、`BAR`、`HISTOGRAM`、`LINE`、`PIE`、`STATISTICS` |
| `table_id` | 必填,当前链路的真实 tableId |
| `measure_type` | `record-count`(默认)或 `field` |
| `measure_field_id` | `measure_type=field` 时必填 |
| `dimension_field_id` | 分组、分类或时间维度需要时填写 |
| `aggregation` | 可选:`sum`、`count`、`count_distinct`、`average`、`min`、`max` |
| `view_id` | 可选;不用视图时省略 |
例如按状态统计记录数:
```json
[{"name":"跟进状态记录数","chart_type":"HISTOGRAM","table_id":"<tableId>","measure_type":"record-count","dimension_field_id":"<状态fieldId>"}]
```
脚本不支持的图表类型或完整高级配置才走下方原子命令;这种例外至多读取一次
`chart widgets-example`,再按真实 tableId/fieldId 构造配置。
## 查询与管理要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
@@ -28,7 +51,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 仅脚本不支持的高级配置按需读取一次 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
@@ -36,11 +59,10 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无;返回很大 | 仅脚本不支持的高级图表按需读取一次 |
## 配置获取流程
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
原子 `chart create` 必须同时传 `--layout`。返回 chartId 后用 `chart get`,或最后
一次 `dashboard get` 核对;回读成功即停止。脚本已自动完成这些动作,不再重复执行。
@@ -1,7 +1,5 @@
# AI 表格数据分析 SOP
> 加载边界:用于需要全表统计、分组、排名或业务结论的任务。普通记录定位只读 record-query 专题。任何“全部/最高/总数”结论都必须携带分页完整性;未拉全时只能报告当前范围和 continuation。
> 当用户诉求涉及查询、筛选、排序、统计、Top/Bottom N、分组聚合、判断全局结论时,必须先读本文档再执行。
## 1. 查询决策树
@@ -1,7 +1,5 @@
# AI 表格错误恢复指南
> 加载边界:仅在真实命令或业务返回失败后读取,不做预防性全量加载。先保留原错误、阶段、对象 ID 和已成功 ledger;只有能证明未写入且契约允许时才重试,状态未知和需确认写操作禁止自动重放。
> 当 CLI 命令返回错误时,按本文档的映射表判断恢复动作。
## 1. 错误响应结构
@@ -1,12 +1,10 @@
# export & import — 导入导出
> 加载边界:仅在文件与 AITable 之间迁移数据时读取。“追加到已有 Table”与“导入成新 Table”必须先分流。`taskId/importId` 只用于对应任务状态,完成后必须验证新 tableId 或本地输出文件;任务已创建不等于业务完成。
## 导出数据(两阶段轮询)
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
优先使用 `python3 scripts/aitable_export_via_task.py <baseId> --scope all|table|view [...]`:它检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。
优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py export <baseId> --scope all|table|view [...]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
@@ -32,7 +30,7 @@ dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 300
> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。
新建数据表导入优先使用 `python3 scripts/aitable_import_via_task.py <baseId> <file>`,脚本封装 prepare → PUT → import 并检查每一步业务状态。追加到已有表且需要字段级类型控制时,使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`;二者语义不同,不要自动互换。
新建数据表导入优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>`;追加到已有表使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;二者语义不同,不要自动互换。
```bash
# 第 1 步:申请上传凭证
@@ -1,7 +1,5 @@
# 字段类型 config 规范(field create / table create / field update)
> 加载边界:仅在创建/更新字段或建表时构造字段 `config`。先确认字段类型及当前 Table;记录 cellValue 结构读取 [aitable-cell-value.md](aitable-cell-value.md),公式正文读取 [aitable-formula-guide.md](aitable-formula-guide.md),不要在本页寻找记录写入路由。
> 适用命令:`dws aitable field create`、`dws aitable table create --fields`、`dws aitable field update --config`
>
> 本文件是 DWS AI 表格字段 config 的 **source of truth**。创建/更新字段时,必须严格按此规范构造 JSON。
@@ -1,7 +1,5 @@
# field — 字段管理
> 加载边界:仅在字段目录不足、需要完整配置或执行字段 CRUD 时读取。`baseId/tableId` 先由根 Skill 解析;写前校验类型、config、关联表和只读性,写后用 field get 回读。删除字段先固化影响并确认。
## field get — 获取字段详情
```
@@ -89,6 +87,8 @@ dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
创建 `lookup`(关联引用)和 `filterUp`(查找引用)字段时,config 格式有严格要求:
当用户要求判断此能力且已授权创建资源时,不要只读 Help/Schema 后下结论:创建最小目标表及必需字段,执行一次真实字段创建并用 `field get` 回读。若没有写入授权,只能报告“接口声明支持但未实际验证”。
#### bidirectionalLink / unidirectionalLink(关联字段)
```bash
@@ -1,7 +1,5 @@
# filters & sort — 筛选排序语法参考
> 加载边界:仅在 record query 或 view 配置需要构造 filter/sort JSON 时读取。先用 field get 取得真实 fieldId 和类型;服务端可过滤时不先拉全量,本页语法不能替代分页完整性判断。
> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
## filters 结构规范
@@ -1,7 +1,5 @@
# form — 表单管理
> 加载边界:仅在用户明确创建、配置或分享表单视图时读取。先绑定当前 Base/Table,再从创建/list 返回取得 viewId 和 fieldId;表单分享不等于记录分享,也不等于开放 Base 权限。
## 命令一览
| 命令 | 用途 |
@@ -1,7 +1,5 @@
# AI 表格公式字段指南
> 加载边界:仅在创建或修改 formula 字段时读取。公式字段是只读派生字段,不能通过 record create/update 写值;字段引用必须基于当前 Table 的精确名称/ID,跨表取值应先分流到 lookup/filterUp。
> 当用户要创建 formula 类型字段、编写表内计算公式、做派生指标时,必须先读本文档。
## 1. 何时使用 formula 字段
@@ -1,7 +1,5 @@
# 主键文档管理
> 加载边界:仅在记录的 primaryDoc 字段需要查询或创建关联文档时读取。先取得真实 baseId/tableId/recordId/fieldId;创建返回 nodeId 后,文档正文交给 Doc Skill,并持续使用该 nodeId,不再按标题搜索。
## 适用场景
当需要为 AI 表格中的记录创建或查询关联的主键文档时使用。主键文档是 primaryDoc 类型字段对应的钉钉在线文档,可通过 `dws doc` 进行内容读写。
@@ -1,7 +1,5 @@
# record create — 新增记录
> 加载边界:仅在目标 Base/Table 已唯一确定且字段目录已取得后读取。第一笔写入前校验全部 cells;创建返回 `newRecordIds[]` 后按 ID 回读。不得因部分字段失败而静默丢列或改类型。
## 命令格式
```
@@ -49,7 +47,7 @@ dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
创建成功以 `data.newRecordIds[]` 为 ID 来源;不要把整个 `data` 当作单个 recordId,也不要只以退出码作为写入成功证据。
批量追加本地 JSON/CSV 到已有表时可使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`。脚本会逐批检查业务状态、收集 `newRecordIds` 并按 ID 回读;任何批次失败或回读不完整都会输出 ledger 并以非零状态结束,已有成功批次仍会保留在结果中。
批量追加本地 JSON/CSV 到已有表时使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口保留逐批业务状态、`newRecordIds` 回读和部分失败 ledger。
## cells 写入格式
@@ -1,7 +1,5 @@
# record delete — 删除记录
> 加载边界:仅在用户明确要求删除记录时读取。先 query 并展示真实 recordId、关键字段和数量,确认前零删除调用;删除后重新按 ID 查询验证不存在,状态未知时禁止自动重试。
## 命令格式
```
@@ -1,7 +1,5 @@
# 行记录变更历史(record history-list)
> 加载边界:仅在审计某条真实 recordId 的历史变更时读取。历史分页与当前记录查询是不同契约;未遍历完成不得声称“全部历史”,历史事件也不能直接当作当前 cells 状态。
按 recordId 查询单条记录的全部变更历史,用于审计、回溯字段变更、定位操作人。
## 命令
@@ -1,7 +1,5 @@
# 行命名规则枚举键(recordNameKey)映射
> 加载边界:仅在配置 Table 的行称谓时读取。自然语言名称必须映射为已审阅枚举键;本页不用于记录查找、字段名解析或写入 cells。
`dws aitable table update --record-name-key <枚举键>` 用于设置数据表的"行命名规则"——卡片/详情页里"行"的展示别名。**取值是固定枚举,不是字段 ID**;传非法值服务端返回 `INVALID_RECORD_NAME_KEY`。
## 中文 → 枚举键(按 UI 下拉顺序)
@@ -1,7 +1,5 @@
# record query — 查询记录
> 加载边界:用于目标 Base/Table 已确定后的记录查询、筛选、排序和分页。按 ID、关键词和 filters 三种模式先分流;全量结论必须检查 `--all`、page-limit、hasMore/nextCursor 和 stop reason,默认一页只能代表当前页。
## 命令格式
```
@@ -35,6 +33,8 @@ Flags:
- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取
- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理)
当精确计数或集合依赖分页查询时,优先使用 `--all --page-limit 0`;否则持续传回 `nextCursor`/`cursor`,直到游标为空且 `hasMore` 不为 true。写入响应返回的完整 ID 列表可直接作为该批证据;截断页、局部计数或最终链接数不能替代分页完成信号。
```bash
# 默认(最多 50 页 = 5000 条)
dws aitable record query --base-id X --table-id Y --all
@@ -1,7 +1,5 @@
# 行记录分享链接(record share-url)
> 加载边界:仅在用户需要某条真实记录的分享链接时读取。recordId 必须属于当前 Base/Table;可选 viewId 也必须同表。生成链接不等于修改记录权限或把链接发送给他人。
按 recordId 批量获取记录的分享链接,把某行单独发给同事查看。
## 命令
@@ -1,7 +1,5 @@
# record update — 更新记录
> 加载边界:仅在 recordId 和待修改 fieldId 已确定后读取。只提交用户要求变更的 cells;写前读取字段类型/只读性,写后使用返回 recordId 回读。未返回 ID、字段未变化或状态未知都不能声称成功。
## 命令格式
```
@@ -1,7 +1,5 @@
# 行记录 Upsert(record upsert)
> 加载边界:仅在同一批次明确混合“带 recordId 更新”和“不带 recordId 创建”时读取。若目标匹配逻辑仍依赖名称或业务键,先 query 并消歧;upsert 不负责猜测 recordId,部分成功必须返回逐项 ledger。
按 `recordId` 是否存在,自动把入参拆分到 update 链路或 create 链路:批次混合"已存在改 + 新出现建"时用,省掉客户端按 ID 分批的逻辑。
## 命令
@@ -0,0 +1,24 @@
# Bundled script recipes
Use this file only for dashboard/chart, import, export, bulk fields, or attachment workflows.
Use `scripts/aitable_ops.py` as the stable entry; do not read implementation files. Use operation-level `--help` only for unclear arguments.
For create-Base-then-script, create and verify the Base, then pass its returned `baseId`; choose a short name if omitted.
| Intent | Exact command |
|---|---|
| Create dashboard, optionally charts | `python3 <Skill绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<dashboardName>" [--chart-specs-file <workspace JSON file>]` |
| Import CSV/XLS/XLSX as a new table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>` |
| Append JSON/CSV to an existing table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size 100]` |
| Export Base/table/view | `python3 <Skill绝对目录>/scripts/aitable_ops.py export <baseId> --scope all\|table\|view [--table-id <id>] [--view-id <id>] [--output <path>]` |
| Add up to 15 fields | `python3 <Skill绝对目录>/scripts/aitable_ops.py add-fields <baseId> <tableId> <fields.json>` |
| Upload attachment | `python3 <Skill绝对目录>/scripts/aitable_ops.py upload-attachment <baseId> <file>` |
## Contracts
- `dashboard` creates the dashboard/charts, chains returned IDs, performs final readback, and emits `dws-skill-script-ledger/v1`. `--chart-specs-file` is a workspace-local file containing one JSON array; it is not inline JSON. Each item accepts `name`, `chart_type`, `table_id`, `measure_type`, `measure_field_id`, `dimension_field_id`, `aggregation`, and `view_id`. Do not add a guessed dashboard command after it.
- `import-new` runs prepare → secure PUT → import task and checks each business status. A returned HTTP upload URL is upgraded to HTTPS without changing host, path, or query. It is not interchangeable with `import-records`, and a deterministic failure is not a reason to expand the same flow manually.
- `import-records` requires CSV headers to be field IDs; use JSON for typed boolean/array/object values. It checks batch results and reads back returned record IDs.
- `export --scope table|view` requires `--table-id`; view also requires `--view-id`. The unified ledger exposes the real `taskId`, `polledTimes`, `savedPath`, and `fileSize`; treat success plus a non-empty saved file as the completion evidence, without reading source or substituting `ls` for task polling. Do not overwrite unless the user explicitly requests it.
- `add-fields` accepts at most 15 items and reports partial failure. `upload-attachment` only returns `fileToken`; write that token to the attachment field and read the record back.
- Preserve nonzero exit, partial-success ledger, timeout, and incomplete readback in the final answer. Do not replace a failed deterministic workflow with guessed atomic commands unless its reported error proves that the script contract is unavailable.
@@ -1,7 +1,5 @@
# 视图配置(view get/update <attr>)
> 加载边界:仅在创建视图或读取/修改视图配置时加载。先确认 viewType 与属性支持矩阵,并绑定当前 Base/Table/viewId;写后读取同一属性验证。复制、锁定、行高和高亮规则改读 [aitable-view-extras.md](aitable-view-extras.md)。
按属性局部读/写视图配置。每个属性独立子命令,typed flag 友好,agent 不必拼 JSON。
向后兼容:`view update --config '{...}'` 一次多属性入口仍可用。
@@ -1,7 +1,5 @@
# 视图扩展操作(lock / frozen-cols / row-height / fill-color-rule / duplicate)
> 加载边界:仅在用户明确操作锁定、冻结列、行高、高亮规则或复制视图时读取。所有动作复用当前 Base/Table/viewId;viewType 不支持时本地停止,写后用对应 get 或新 viewId 回读。
本文档讲 5 项视图操作命令:
- 锁定 / 解锁视图:`view lock` / `view get lock`
@@ -1,7 +1,5 @@
# workflow — 自动化工作流管理
> 加载边界:仅在创建、全量更新、启停或检查 AITable 自动化工作流时读取。create/update 使用完整 DSL,先校验并保留现状;disable 等需确认动作确认前零写入,返回 flowId 后用 get/list 验证 `valid/issues/status`。
创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。
@@ -1,7 +1,5 @@
# 易混淆操作与字段规则
> 定位:字段操作的轻量防错入口,仅在建表/建字段前快速核对主字段、只读字段和附件边界。完整 config 读取 `aitable/aitable-field-properties.md`,cellValue 读取 `aitable/aitable-cell-value.md`;不要与两份专题同时全量加载。
## 易混淆操作 (高风险场景必读)
| 用户说的 | 正确命令 | 不是这个 |
@@ -38,13 +36,6 @@
## 字段创建时设置 config(重要)
字段 JSON 的命名和枚举是严格契约:
- 批量 `--fields` 每项使用 `fieldName`,不是 `name`。
- 单选是 `singleSelect`,多选是 `multipleSelect`;不存在通用的 `select` 类型。
- number/date/currency formatter 只使用当前 `table create --help` 或 leaf Schema 列出的枚举;不要从别的产品或旧样例猜 `INTEGER`。
- 第一次返回明确 hint 后只按 hint 修正一次;第二次仍失败就停止并保留部分成功 ledger。
创建 singleSelect/multipleSelect 字段时,**必须设置选项 (options)**:
```bash
@@ -100,11 +91,11 @@ dws aitable table create --base-id <BASE_ID> --name "产品图片" \
> **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。
附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成:
附件字段写入使用统一 `aitable_ops.py upload-attachment` 入口,**2 步**完成:
```bash
# 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/photo.png
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/photo.png
# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 }
# 步骤 2: 在 record create/update 中使用 fileToken
@@ -1,7 +1,5 @@
# aitable 局部意图消歧
> 定位:只处理 AITable 与 Sheet、Doc、Drive、Minutes 等 sibling 产品的边界;产品内 Base/Table/Field/Record 路由由根 Skill 负责。用户意图已经明确属于 AITable 时无需加载本页。
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
@@ -1,7 +1,5 @@
# URL 格式与处理规范
> 加载边界:仅在用户给出类型不明的 alidocs URL 且意图不足以直接分流时读取。明确的 AITable 操作优先按意图执行;探测只负责确定资源类型,不应成为每次调用前置步骤,也不能把短链或裸 key 猜成 baseId。
## 路由第 0 步:意图直达(优先级高于 URL 探测)
用户已经明确表达某产品的内容意图时,直接进入对应产品场域,不要先做 URL 类型
@@ -17,7 +17,6 @@ from __future__ import annotations
import argparse
import json
import re
import ssl
import subprocess
import sys
import time
@@ -27,6 +26,7 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
# Runtime AI Table IDs are opaque and some table IDs are seven characters.
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$")
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
@@ -65,12 +65,7 @@ def normalize_download_url(url: str) -> str:
def download_file(url: str, output_path: Path) -> Tuple[bool, str]:
req = Request(url, method="GET")
try:
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
with urlopen(req, timeout=180, context=context) as resp:
with urlopen(req, timeout=180) as resp:
redirected = urlparse(resp.geturl())
if redirected.scheme != "https" or not redirected.hostname:
return False, "download redirect is not HTTPS"
@@ -17,16 +17,15 @@ from __future__ import annotations
import argparse
import json
import re
import ssl
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$")
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"}
@@ -34,24 +33,6 @@ def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def normalize_oss_upload_url(upload_url: str) -> str:
"""Accept the signed OSS URL returned by Runtime and upgrade it to HTTPS."""
parsed = urlparse(upload_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("uploadUrl must be a valid HTTP(S) URL")
if not parsed.hostname.endswith(".oss.aliyuncs.com"):
raise ValueError("uploadUrl host is not an Aliyun OSS endpoint")
return parsed._replace(scheme="https").geturl()
def tls_context() -> ssl.SSLContext:
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
@@ -71,17 +52,28 @@ def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
return None
def normalize_upload_url(upload_url: str) -> str:
parsed = urlparse(upload_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("uploadUrl must be a valid HTTP(S) URL")
if parsed.username or parsed.password:
raise ValueError("uploadUrl must not contain user info")
if parsed.scheme == "http":
parsed = parsed._replace(scheme="https")
return urlunparse(parsed)
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
try:
upload_url = normalize_oss_upload_url(upload_url)
secure_upload_url = normalize_upload_url(upload_url)
except ValueError as exc:
return False, str(exc)
payload = file_path.read_bytes()
req = Request(upload_url, data=payload, method="PUT")
req = Request(secure_upload_url, data=payload, method="PUT")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
with urlopen(req, timeout=180, context=tls_context()) as resp:
with urlopen(req, timeout=180) as resp:
if resp.status == 200:
return True, ""
return False, f"unexpected HTTP status: {resp.status}"
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Stable black-box entry point for bundled AI Table workflows."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
LEDGER_SCHEMA_VERSION = "dws-skill-script-ledger/v1"
def command_for(args: argparse.Namespace) -> list[str]:
if args.operation == "dashboard":
command = [
"create_dashboard_chart.py", args.base_id, args.dashboard_name,
*(["--chart-specs-file", args.chart_specs_file] if args.chart_specs_file else []),
]
elif args.operation == "import-new":
command = ["aitable_import_via_task.py", args.base_id, args.file]
elif args.operation == "import-records":
command = [
"import_records.py", args.base_id, args.table_id, args.file,
str(args.batch_size),
]
elif args.operation == "export":
command = [
"aitable_export_via_task.py", args.base_id, "--scope", args.scope,
*(["--table-id", args.table_id] if args.table_id else []),
*(["--view-id", args.view_id] if args.view_id else []),
*(["--output", args.output] if args.output else []),
*(["--export-format", args.export_format] if args.export_format else []),
*(["--overwrite"] if args.overwrite else []),
]
elif args.operation == "add-fields":
command = [
"bulk_add_fields.py", args.base_id, args.table_id, args.fields_file,
]
else:
command = ["upload_attachment.py", args.base_id, args.file]
return [sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]]
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(
description=(
"Run a bundled AI Table workflow without reading implementation source. "
"Each operation preserves the underlying script ledger and exit status."
)
)
sub = root.add_subparsers(dest="operation", required=True)
dashboard = sub.add_parser("dashboard", help="create and read back a dashboard/chart")
dashboard.add_argument("base_id")
dashboard.add_argument("dashboard_name")
dashboard.add_argument(
"--chart-specs-file", "--chart-specs", dest="chart_specs_file",
help="workspace-local file containing a JSON array of chart specs",
)
import_new = sub.add_parser("import-new", help="import CSV/XLS/XLSX as a new table")
import_new.add_argument("base_id")
import_new.add_argument("file")
import_records = sub.add_parser("import-records", help="append JSON/CSV records to an existing table")
import_records.add_argument("base_id")
import_records.add_argument("table_id")
import_records.add_argument("file")
import_records.add_argument("--batch-size", type=int, default=100)
export = sub.add_parser("export", help="export a Base, table, or view")
export.add_argument("base_id")
export.add_argument("--scope", choices=("all", "table", "view"), required=True)
export.add_argument("--table-id")
export.add_argument("--view-id")
export.add_argument("--output")
export.add_argument(
"--export-format",
choices=("attachment", "excel", "excel_and_attachment", "excel_with_inline_images"),
)
export.add_argument("--overwrite", action="store_true")
fields = sub.add_parser("add-fields", help="create up to 15 fields from JSON")
fields.add_argument("base_id")
fields.add_argument("table_id")
fields.add_argument("fields_file")
attachment = sub.add_parser("upload-attachment", help="upload a file and return fileToken")
attachment.add_argument("base_id")
attachment.add_argument("file")
return root
def normalize_output(raw: str, args: argparse.Namespace | None = None) -> str:
"""Make a delegated trusted ledger attributable to this stable entry point."""
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return raw
if (
isinstance(payload, dict)
and payload.get("schema_version") == LEDGER_SCHEMA_VERSION
and isinstance(payload.get("script"), str)
):
payload["implementation_script"] = payload["script"]
payload["script"] = Path(__file__).name
return json.dumps(payload, ensure_ascii=False)
if args and args.operation == "export" and isinstance(payload, dict):
status = str(payload.get("status") or "error")
saved_path = str(payload.get("savedPath") or "")
file_size = 0
if saved_path:
try:
file_size = Path(saved_path).stat().st_size
except OSError:
file_size = 0
task_id = str(payload.get("taskId") or "")
ledger_status = "success" if status == "success" and task_id and (
bool(payload.get("downloadUrl")) and (not saved_path or file_size > 0)
) else status
ledger = {
"schema_version": LEDGER_SCHEMA_VERSION,
"script": Path(__file__).name,
"implementation_script": "aitable_export_via_task.py",
"status": ledger_status,
"result": payload,
"ledger": [{
"cli_path": "aitable export data",
"status": ledger_status,
"params": {
"base-id": args.base_id,
"scope": args.scope,
**({"table-id": args.table_id} if args.table_id else {}),
**({"view-id": args.view_id} if args.view_id else {}),
},
"output_ids": {
"taskId": task_id,
"polledTimes": int(payload.get("polledTimes") or 0),
"savedPath": saved_path,
"fileSize": file_size,
},
"error": "" if ledger_status == "success" else str(payload.get("summary") or "export incomplete"),
}],
}
return json.dumps(ledger, ensure_ascii=False)
return raw
def main() -> int:
args = parser().parse_args()
if args.operation == "export":
if args.scope in {"table", "view"} and not args.table_id:
parser().error("export --scope table|view requires --table-id")
if args.scope == "view" and not args.view_id:
parser().error("export --scope view requires --view-id")
result = subprocess.run(command_for(args), check=False, capture_output=True, text=True)
if result.stdout:
print(normalize_output(result.stdout, args), end="" if result.stdout.endswith("\n") else "\n")
if result.stderr:
print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n")
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Create an AI Table dashboard and optional common charts deterministically.
Examples:
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘"
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘" --chart-specs-file charts.json
charts.json is a JSON array. Each item accepts:
name, chart_type, table_id, measure_type, measure_field_id,
dimension_field_id, aggregation, view_id.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
LEDGER_SCHEMA_VERSION = "dws-skill-script-ledger/v1"
SCRIPT_NAME = "create_dashboard_chart.py"
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,128}$")
SUPPORTED_CHART_TYPES = {"AREA", "BAR", "HISTOGRAM", "LINE", "PIE", "STATISTICS"}
SUPPORTED_AGGREGATIONS = {"sum", "count", "count_distinct", "average", "avg", "min", "max"}
MAX_CHARTS = 6
def run_dws(dws_bin: str, args: list[str]) -> tuple[Optional[dict[str, Any]], str]:
try:
completed = subprocess.run(
[dws_bin, *args], capture_output=True, text=True, timeout=120
)
except subprocess.TimeoutExpired:
return None, "dws command timeout after 120 seconds"
except FileNotFoundError:
return None, f"dws binary not found: {dws_bin}"
if completed.returncode != 0:
return None, (completed.stderr or completed.stdout).strip()[:800]
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
return None, f"dws returned non-JSON output: {exc}"
if not isinstance(payload, dict) or payload.get("status") != "success":
return None, f"dws business failure: {payload}"
return payload, ""
def safe_json_file(value: str) -> Any:
root = Path(os.environ.get("OPENCLAW_WORKSPACE", os.getcwd())).resolve()
source = Path(value).expanduser()
source = source.resolve() if source.is_absolute() else (Path.cwd() / source).resolve()
try:
source.relative_to(root)
except ValueError as exc:
raise ValueError(f"chart specs must be inside the workspace: {source}") from exc
if not source.is_file() or source.stat().st_size > 1024 * 1024:
raise ValueError("chart specs must be a readable JSON file no larger than 1 MiB")
with source.open("r", encoding="utf-8") as stream:
return json.load(stream)
def validate_specs(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value or len(value) > MAX_CHARTS:
raise ValueError(f"chart specs must contain 1-{MAX_CHARTS} items")
specs: list[dict[str, Any]] = []
for index, item in enumerate(value, start=1):
if not isinstance(item, dict):
raise ValueError(f"chart spec #{index} must be an object")
name = str(item.get("name") or "").strip()
chart_type = str(item.get("chart_type") or "").strip().upper()
table_id = str(item.get("table_id") or "").strip()
measure_type = str(item.get("measure_type") or "record-count").strip()
measure_field_id = str(item.get("measure_field_id") or "").strip()
dimension_field_id = str(item.get("dimension_field_id") or "").strip()
aggregation = str(item.get("aggregation") or "sum").strip().lower()
view_id = str(item.get("view_id") or "").strip()
if not name or len(name) > 80:
raise ValueError(f"chart spec #{index} needs a 1-80 character name")
if chart_type not in SUPPORTED_CHART_TYPES:
raise ValueError(f"chart spec #{index} has unsupported chart_type: {chart_type}")
if not RESOURCE_ID_PATTERN.fullmatch(table_id):
raise ValueError(f"chart spec #{index} has invalid table_id")
if measure_type not in {"record-count", "field"}:
raise ValueError(f"chart spec #{index} has invalid measure_type")
if measure_type == "field" and not RESOURCE_ID_PATTERN.fullmatch(measure_field_id):
raise ValueError(f"chart spec #{index} needs measure_field_id")
if aggregation not in SUPPORTED_AGGREGATIONS:
raise ValueError(f"chart spec #{index} has unsupported aggregation")
for label, resource_id in (
("dimension_field_id", dimension_field_id), ("view_id", view_id)
):
if resource_id and not RESOURCE_ID_PATTERN.fullmatch(resource_id):
raise ValueError(f"chart spec #{index} has invalid {label}")
specs.append(
{
"name": name,
"chart_type": chart_type,
"table_id": table_id,
"measure_type": measure_type,
"measure_field_id": measure_field_id,
"dimension_field_id": dimension_field_id,
"aggregation": "average" if aggregation == "avg" else aggregation,
"view_id": view_id,
}
)
return specs
def chart_config(spec: dict[str, Any]) -> dict[str, Any]:
config: dict[str, Any] = {
"chartType": spec["chart_type"],
"name": spec["name"],
"sheet": spec["table_id"],
"view": spec["view_id"] or None,
"measureType": spec["measure_type"],
"measure": [],
"filter": [],
}
if spec["measure_type"] == "field":
config["measure"] = [
{
"value": spec["measure_field_id"],
"externalValue": [{"type": "formula", "value": spec["aggregation"]}],
}
]
if spec["dimension_field_id"]:
config["dimension"] = [
{"value": spec["dimension_field_id"], "externalValue": []}
]
if spec["chart_type"] in {"AREA", "BAR", "HISTOGRAM", "LINE", "PIE"}:
config.update({"colors": "COLOR_PALETTE_1", "legend": "top", "label": True})
if spec["chart_type"] in {"AREA", "BAR", "HISTOGRAM", "LINE"}:
config.update({"xAxisShow": True, "yAxisShow": True})
if spec["chart_type"] == "PIE":
config.update({"innerRadius": 0, "outerRadius": 60})
return config
def layout(index: int, total: int) -> dict[str, int]:
width = 12 if total == 1 else 6 if total in {2, 3, 4} else 4
per_row = 12 // width
return {"x": (index % per_row) * width, "y": (index // per_row) * 5, "w": width, "h": 5}
def extract_id(payload: dict[str, Any], key: str) -> str:
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
value = data.get(key)
return str(value or "").strip()
def dashboard_chart_ids(payload: dict[str, Any]) -> set[str]:
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
charts = data.get("charts") if isinstance(data.get("charts"), list) else []
return {
str(item.get("chartId"))
for item in charts
if isinstance(item, dict) and item.get("chartId")
}
def ledger_step(
cli_path: str, status: str, params: dict[str, Any], output_ids: Optional[dict[str, str]] = None,
error: str = "",
) -> dict[str, Any]:
return {
"cli_path": cli_path,
"status": status,
"params": params,
"output_ids": output_ids or {},
"error": error,
}
def emit(status: str, ledger: list[dict[str, Any]], **result: Any) -> None:
print(
json.dumps(
{
"schema_version": LEDGER_SCHEMA_VERSION,
"script": SCRIPT_NAME,
"status": status,
"result": result,
"ledger": ledger,
},
ensure_ascii=False,
)
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base_id", help="Target AI Table baseId")
parser.add_argument("dashboard_name", help="Dashboard name")
parser.add_argument(
"--chart-specs-file", "--chart-specs", dest="chart_specs_file",
help="Workspace-local file containing a JSON array of chart specs",
)
parser.add_argument("--dws", default="dws", help="dws executable")
args = parser.parse_args()
base_id = args.base_id.strip()
dashboard_name = args.dashboard_name.strip()
if not RESOURCE_ID_PATTERN.fullmatch(base_id) or not dashboard_name:
parser.error("base_id and dashboard_name are required and must be valid")
try:
specs = validate_specs(safe_json_file(args.chart_specs_file)) if args.chart_specs_file else []
except (OSError, ValueError, json.JSONDecodeError) as exc:
parser.error(str(exc))
ledger: list[dict[str, Any]] = []
dashboard_params = {"base-id": base_id, "name": dashboard_name, "format": "json"}
dashboard, error = run_dws(
args.dws,
["aitable", "dashboard", "create", "--base-id", base_id, "--name", dashboard_name, "--format", "json"],
)
if not dashboard:
ledger.append(ledger_step("aitable dashboard create", "failed", dashboard_params, error=error))
emit("failed", ledger, error=error)
return 1
dashboard_id = extract_id(dashboard, "dashboardId")
if not dashboard_id:
error = "dashboard create returned no dashboardId"
ledger.append(ledger_step("aitable dashboard create", "failed", dashboard_params, error=error))
emit("failed", ledger, error=error)
return 1
ledger.append(
ledger_step(
"aitable dashboard create", "success", dashboard_params,
{"dashboardId": dashboard_id},
)
)
chart_ids: list[str] = []
for index, spec in enumerate(specs):
config = chart_config(spec)
chart_layout = layout(index, len(specs))
params = {
"base-id": base_id,
"dashboard-id": dashboard_id,
"config": config,
"layout": chart_layout,
"format": "json",
}
chart, error = run_dws(
args.dws,
[
"aitable", "chart", "create", "--base-id", base_id,
"--dashboard-id", dashboard_id,
"--config", json.dumps(config, ensure_ascii=False, separators=(",", ":")),
"--layout", json.dumps(chart_layout, separators=(",", ":")),
"--format", "json",
],
)
if not chart:
ledger.append(ledger_step("aitable chart create", "failed", params, error=error))
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
return 1
chart_id = extract_id(chart, "chartId")
if not chart_id:
error = "chart create returned no chartId"
ledger.append(ledger_step("aitable chart create", "failed", params, error=error))
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
return 1
chart_ids.append(chart_id)
ledger.append(
ledger_step("aitable chart create", "success", params, {"chartId": chart_id})
)
get_params = {"base-id": base_id, "dashboard-id": dashboard_id, "format": "json"}
verified, error = run_dws(
args.dws,
["aitable", "dashboard", "get", "--base-id", base_id, "--dashboard-id", dashboard_id, "--format", "json"],
)
if not verified:
ledger.append(ledger_step("aitable dashboard get", "failed", get_params, error=error))
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
return 1
missing_chart_ids = set(chart_ids) - dashboard_chart_ids(verified)
if missing_chart_ids:
error = "dashboard verification missing chartIds: " + ",".join(sorted(missing_chart_ids))
ledger.append(ledger_step("aitable dashboard get", "failed", get_params, error=error))
emit("failed", ledger, dashboardId=dashboard_id, chartIds=chart_ids, error=error)
return 1
ledger.append(
ledger_step("aitable dashboard get", "success", get_params, {"dashboardId": dashboard_id})
)
emit("success", ledger, dashboardId=dashboard_id, chartIds=chart_ids)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -25,14 +25,13 @@ import subprocess
import os
import mimetypes
import re
import ssl
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{6,128}$')
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
@@ -78,24 +77,15 @@ def run_dws(args: list, dws_bin: str = 'dws') -> Optional[Dict[str, Any]]:
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
parsed = urlparse(upload_url)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
print('错误:uploadUrl 必须是有效的 HTTP(S) URL', file=sys.stderr)
if parsed.scheme != 'https' or not parsed.hostname:
print('错误:uploadUrl 必须是有效的 HTTPS URL', file=sys.stderr)
return False
if not parsed.hostname.endswith('.oss.aliyuncs.com'):
print('错误:uploadUrl 不是阿里云 OSS 地址', file=sys.stderr)
return False
upload_url = parsed._replace(scheme='https').geturl()
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
with urlopen(req, timeout=120, context=context) as resp:
with urlopen(req, timeout=120) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
+2
View File
@@ -72,6 +72,8 @@ metadata:
- 已有 callout、分栏、样式、@人、图片或附件时,先读 JSONML;局部改动优先 `block update`,不要用 Markdown 整篇重写。
- `block insert` 默认追加;只有明确相对位置时才传真实 `--ref-block` / `--parent-block`。`block delete` 和评论删除必须确认。
- 写后按对象验证:正文用 `doc read`,块/附件用 `doc block list`,元信息/链接用 `doc info`,版本用 `version list`。
- 工具退出码 0、`success=true`、空对象或 `null` 都不能单独证明成功。每个写步骤必须同时有非空业务结果和针对目标字段的回查;例如 comment update 返回 `null` 且 list 仍是旧内容时,该步骤失败,最终必须报告“部分完成/更新未生效”,不得以“全部完成”开头。
- 汇总和改写只能重组用户给出的事实,不得增强确定性或新增任务:“验证 12 条用例”不能写成“12 条全部通过”,“整理问题清单”不能扩写成“输出根因分析”。数字、状态、结论和承诺逐项保持原义。
## 低频 Reference
@@ -58,7 +58,9 @@ dws doc read --node <nodeId> # 校验关键标题、段落首句、表格、@
**禁止**在未回读的情况下向用户报告「已完成」。
## 显式工作流
## 显式工作流与事实保真
- 用户点名的 `create → list → insert/append/update` 是可观察命令链,必须保持顺序逐项执行;create 只承载明确的初始正文。有序列表块必须验证回读结构中的 `list.isOrdered=true`。
- 新建资源返回 ID 后,同一请求的指代默认绑定该新资源;禁止搜索同名旧资源替换绑定。
- 汇总用户材料时保留证据强度:验证数量不等于通过数量,计划整理问题不等于承诺根因分析。不得为“更专业”而补造结论、状态或任务。
- 任一步骤返回 `null`/空结果或回查不一致时,该步骤未完成;最终按步骤报告成功与失败,不能用其他成功步骤把整体描述成“全部完成”。
@@ -132,6 +132,7 @@ Flags:
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `reply --emoji` 与群 mention 冲突;CLI 会在调用服务端前报错,不会静默忽略。
- `delete` 是不可逆操作;AI Agent 必须先让用户确认,再追加 `--yes`,避免 CLI 进入交互等待。
- `comment create/reply/update/delete` 的退出码 0 不等于业务成功。响应为 `null`、空对象或缺少可核验字段时,立即执行 `comment list` 回查目标 `commentKey`。若 update 后正文仍是旧值,必须判定“更新未生效”;即使其他步骤成功或评论随后被删除,也只能报告部分完成,禁止写“全部完成”。
## 上下文传递
@@ -60,6 +60,8 @@ def run_dws(args: Sequence[str], dry_run: bool = False) -> Any:
f"dws 命令失败:{detail or f'退出码 {result.returncode}'}"
)
data = decode_json_output(result.stdout)
if data is None or data == {}:
raise ScriptError("dws 返回空业务结果,无法确认操作成功")
if isinstance(data, dict) and data.get("success") is False:
detail = data.get("errorMsg") or data.get("message") or "未知错误"
raise ScriptError(f"dws 业务调用失败:{detail}")
@@ -145,12 +147,14 @@ def run(argv: Optional[Sequence[str]] = None) -> int:
["doc", "info", "--node", node_id, "--format", "json"],
dry_run=args.dry_run,
)
run_dws(
readback = run_dws(
["doc", "read", "--node", node_id, "--format", "json"],
dry_run=args.dry_run,
)
if args.dry_run:
return 0
if not first_value(readback, ("markdown", "jsonml", "content")):
raise ScriptError("文档回读未返回正文,无法确认写入成功")
summary = {
"success": True,
+1 -1
View File
@@ -68,7 +68,7 @@ metadata:
**触发**:在知识库建文档/页面。
1. **执行(必须)**:`dws wiki node create --workspace <workspaceId> --type adoc --name "<名称>" --format json`(按需 `--parent-id <父节点>`);返回取 `nodeId`。
2. **写内容(必须)**:节点内容编辑切 `dingtalk-doc`。追加用 `dws doc update --node <nodeId> --mode append --content-file <tmp.md>`;只有用户确认整篇覆盖后才用 `dws doc update --node <nodeId> --mode overwrite --content-file <tmp.md> --yes`。写后都要 `doc read` 回读。
2. **写内容(必须)**:节点内容编辑切 `dingtalk-doc`,用 `dws doc update --node <nodeId> --mode overwrite|append --content-file <tmp.md> --yes`;写后 `doc read` 回读。
3. **验证(必须)**:`dws wiki node list --workspace <workspaceId> --format json` 复核节点已建。
**禁止**:在 wiki 内直接拼内容(应切 doc 写)、建后不回读。
+83 -3
View File
@@ -25,6 +25,7 @@ BULK_FIELDS = load_module("aitable_bulk_fields", "bulk_add_fields.py")
UPLOAD_ATTACHMENT = load_module("aitable_upload_attachment", "upload_attachment.py")
EXPORT_TASK = load_module("aitable_export_task", "aitable_export_via_task.py")
IMPORT_TASK = load_module("aitable_import_task", "aitable_import_via_task.py")
AITABLE_OPS = load_module("aitable_ops", "aitable_ops.py")
class AITableSkillScriptsTest(unittest.TestCase):
@@ -59,6 +60,81 @@ class AITableSkillScriptsTest(unittest.TestCase):
self.assertEqual(records[0]["cells"]["fldPhone01"], "00123")
self.assertEqual(records[0]["cells"]["fldBool01"], "true")
def test_unified_ops_dispatches_without_cross_operation_arguments(self):
cases = [
(
["dashboard", "base12345678", "概览", "--chart-specs-file", "charts.json"],
"create_dashboard_chart.py",
["base12345678", "概览", "--chart-specs-file", "charts.json"],
),
(
["import-new", "base12345678", "data.csv"],
"aitable_import_via_task.py",
["base12345678", "data.csv"],
),
(
["import-records", "base12345678", "table1234567", "data.json"],
"import_records.py",
["base12345678", "table1234567", "data.json", "100"],
),
(
["export", "base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"],
"aitable_export_via_task.py",
["base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"],
),
(
["add-fields", "base12345678", "table1234567", "fields.json"],
"bulk_add_fields.py",
["base12345678", "table1234567", "fields.json"],
),
(
["upload-attachment", "base12345678", "report.pdf"],
"upload_attachment.py",
["base12345678", "report.pdf"],
),
]
parser = AITABLE_OPS.parser()
for argv, script, expected_tail in cases:
with self.subTest(operation=argv[0]):
command = AITABLE_OPS.command_for(parser.parse_args(argv))
self.assertEqual(Path(command[1]).name, script)
self.assertEqual(command[2:], expected_tail)
def test_unified_ops_normalizes_delegated_trusted_ledger(self):
payload = {
"schema_version": "dws-skill-script-ledger/v1",
"script": "create_dashboard_chart.py",
"status": "success",
"ledger": [],
}
normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload)))
self.assertEqual(normalized["script"], "aitable_ops.py")
self.assertEqual(normalized["implementation_script"], "create_dashboard_chart.py")
def test_unified_ops_emits_export_task_ledger_with_nonempty_file(self):
with tempfile.TemporaryDirectory() as raw:
exported = Path(raw) / "result.xlsx"
exported.write_bytes(b"excel")
args = AITABLE_OPS.parser().parse_args([
"export", "base12345678", "--scope", "table",
"--table-id", "tbl1234", "--output", str(exported),
])
payload = {
"status": "success",
"taskId": "task12345678",
"downloadUrl": "https://example.invalid/result.xlsx",
"polledTimes": 2,
"savedPath": str(exported),
}
normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload), args))
self.assertEqual(normalized["script"], "aitable_ops.py")
self.assertEqual(normalized["ledger"][0]["cli_path"], "aitable export data")
self.assertEqual(normalized["ledger"][0]["output_ids"]["polledTimes"], 2)
self.assertEqual(normalized["ledger"][0]["output_ids"]["fileSize"], 5)
def test_export_accepts_runtime_seven_character_table_id(self):
self.assertTrue(EXPORT_TASK.validate_resource_id("VS9pVmc"))
def test_import_records_checks_ids_and_readback(self):
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
@@ -175,7 +251,7 @@ elif args[:3] == ['aitable', 'field', 'get']:
self.assertEqual(payload["verifiedCount"], 1)
self.assertEqual(payload["ledger"][1]["error"], "duplicate")
def test_upload_helpers_reject_plain_http_urls(self):
def test_upload_helpers_use_secure_urls(self):
with tempfile.TemporaryDirectory() as raw:
file_path = Path(raw) / "file.bin"
file_path.write_bytes(b"x")
@@ -184,9 +260,13 @@ elif args[:3] == ['aitable', 'field', 'get']:
"http://example.com/upload", file_path, "application/octet-stream"
)
)
ok, error = IMPORT_TASK.put_file("http://example.com/upload", file_path)
self.assertEqual(
IMPORT_TASK.normalize_upload_url("http://example.com/upload?signature=x"),
"https://example.com/upload?signature=x",
)
ok, error = IMPORT_TASK.put_file("ftp://example.com/upload", file_path)
self.assertFalse(ok)
self.assertIn("HTTPS", error)
self.assertIn("HTTP(S)", error)
def test_export_helpers_reject_http_and_existing_output(self):
with self.assertRaises(ValueError):
+39 -1
View File
@@ -96,7 +96,7 @@ class DocSkillAlignmentTest(unittest.TestCase):
self.assertIn("按“先/再/然后”切分操作阶段", skill)
self.assertLessEqual(len(skill.encode("utf-8")), 9500)
def test_workflow_and_identity_rules_are_explicit(self):
def test_workflow_identity_and_fidelity_rules_are_explicit(self):
skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
create_refs = "\n".join(
path.read_text(encoding="utf-8")
@@ -119,11 +119,22 @@ class DocSkillAlignmentTest(unittest.TestCase):
MONO_DOC_ROOT / "doc-import.md",
]
)
comment_refs = "\n".join(
path.read_text(encoding="utf-8")
for path in [
SKILL_ROOT / "references" / "doc" / "doc-comment.md",
MONO_DOC_ROOT / "doc-comment.md",
]
)
self.assertIn("不能覆盖用户显式要求的正文 H1", skill)
self.assertIn("禁止先搜索同名文档", create_refs)
self.assertIn("显式块操作不可折叠", block_refs)
self.assertIn("list.isOrdered=true", block_refs)
self.assertIn("在线编辑硬路由", import_refs)
self.assertIn("update 后正文仍是旧值", comment_refs)
self.assertIn("验证 12 条用例", skill)
self.assertIn("部分完成/更新未生效", skill)
def test_schema_selection_preserves_doc_drive_boundaries(self):
doc = json.loads(
@@ -150,6 +161,10 @@ class DocSkillAlignmentTest(unittest.TestCase):
"list.isOrdered=true",
" ".join(doc["doc.insert_document_block"]["use_when"]),
)
self.assertIn(
"null/空对象",
" ".join(doc["doc.update_comment"]["avoid_when"]),
)
self.assertIn(
"dws doc import",
" ".join(drive["drive.upload"]["avoid_when"]),
@@ -213,6 +228,16 @@ class DocCreateAndWriteTest(unittest.TestCase):
with self.assertRaisesRegex(self.module.ScriptError, "denied"):
self.module.run_dws(["doc", "create"])
with mock.patch.object(
self.module.subprocess,
"run",
return_value=subprocess.CompletedProcess(
["dws"], 0, stdout="null", stderr=""
),
):
with self.assertRaisesRegex(self.module.ScriptError, "空业务结果"):
self.module.run_dws(["doc", "create"])
def test_wrapper_uses_create_then_info_and_read_without_manual_update(self):
calls = []
@@ -254,6 +279,19 @@ class DocCreateAndWriteTest(unittest.TestCase):
self.assertEqual(0, code)
self.assertEqual(["# 周报"], seen_content)
def test_wrapper_rejects_empty_readback(self):
def fake_run(args, dry_run=False):
if args[:2] == ["doc", "create"]:
return {"success": True, "nodeId": "doc-1"}
if args[:2] == ["doc", "info"]:
return {"success": True, "docUrl": "https://example.test/doc-1"}
return {"success": True, "markdown": ""}
with mock.patch.object(self.module, "run_dws", side_effect=fake_run):
with self.assertRaisesRegex(self.module.ScriptError, "回读未返回正文"):
self.module.run(["--name", "周报", "--content", "hello"])
def test_dry_run_shows_create_and_verification_commands(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):