diff --git a/.changes/wiki-shortcut-alignment.md b/.changes/wiki-shortcut-alignment.md new file mode 100644 index 00000000..356fb40d --- /dev/null +++ b/.changes/wiki-shortcut-alignment.md @@ -0,0 +1,8 @@ +--- +category: Added +--- + +- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and + activity shortcuts with strict collection validation, cursor handling, + write read-backs, Lark-compatible task routing, and documented backend + boundaries. diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8e025520..eef23b40 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-12T00:10:44.511794", - "count": 399, + "generated_at": "2026-08-14T10:41:08.959563", + "count": 418, "results": [ { "suite": "semantic", @@ -3659,11 +3659,204 @@ "status": "real-ok" }, { - "suite": "read", + "suite": "semantic", + "service": "wiki", + "command": "+delete-space", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+feed-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "补充 Lark Shortcut 缺失的知识库协作动态查询,严格验证 feeds 并保留游标。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-add", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "批量授予 Wiki 容器角色,严格校验写终态并读取成员列表验证响应链。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-remove", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "批量移除容器访问,严格校验写终态并读取成员列表验证响应链。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+member-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "补充 Lark Shortcut 缺失的成员角色更新,并执行终态与列表读回校验。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+move", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+move-to-drive", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-copy", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+node-search", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "补充 Lark Wiki Shortcut 缺失的库内关键词和扩展名搜索,并拒绝假空结果。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+resolve-space", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "提供 Lark Shortcut 未单列的知识库详情入口,并要求 workspaceId 业务证据。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+space-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。", + "availability": "available" + }, + { + "suite": "semantic", "service": "wiki", "command": "+space-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "wiki", + "command": "+wiki-new-doc", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。", + "availability": "available" } ] } \ No newline at end of file diff --git a/docs/wiki-shortcut-business-review.html b/docs/wiki-shortcut-business-review.html new file mode 100644 index 00000000..4d7c871e --- /dev/null +++ b/docs/wiki-shortcut-business-review.html @@ -0,0 +1,115 @@ + + + + + + DWS Wiki Shortcut 全景评审 + + +
+
+

DWS Wiki Shortcut 全景评审

+

以 lark-cli 1.0.78 的 13 个 Wiki Shortcut 为任务基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。

+
评审日期 2026-08-14独立 worktree / 独立分支真实组织数据 E2E报告已去标识化
+
+ +
+
20公开 Wiki Shortcuts
+
13/13Lark 用户任务有对应路径
+
7DWS 额外场景
+
20/20真实数据能力已触达
+
+ +
+

结论先行

+
DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。 Lark 的 13 个用户任务均有对应入口;其中 DWS 额外提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。
+
不能假装完全同构。 DingTalk 成员接口不提供游标,单次真实上限是 50,因此无法诚实实现 Lark 的成员 --page-all;成员身份只接受同组织可用的 userId,不能对齐 Lark 的 email/open_id 等多种 member-id-type;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。
+
+ +
+

Lark 13 项逐条映射

+
+ + + + + + + + + + + + + +
Lark ShortcutDWS 主入口结论DWS 视角与边界
+space-listwiki +space-list完整对齐严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。
+space-createwiki +space-create超过公开真实 32 字符名称上限;创建后按 workspaceId 读回。
+delete-spacewiki +delete-space超过预读目标、高风险确认、只接受 success=true;兼容 +space-delete。
+member-addwiki +member-add任务对齐支持 1–30 个 userId 与四种角色,读回名单和角色;不支持 email/open_id 等身份类型。
+member-listwiki +member-list任务对齐严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。
+member-removewiki +member-remove任务对齐批量 userId,移除后读回确认成员已消失;身份类型边界同上。
+node-listwiki +node-list完整对齐正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。
+node-getwiki +node-get任务对齐支持 DingTalk 节点 ID/在线文档 URL并返回文档域元数据;不接受 Lark 特有 token/type 组合。
+node-createwiki +node-create任务对齐支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。
+node-copywiki +node-copy超过确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。
+movewiki +move任务对齐同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;无 Lark apply 权限迁移开关。
+move-to-drivewiki +move-to-drive超过DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。
+node-deletewiki +node-delete超过预读并核对 workspace,高风险确认,要求删除终态。
+
+ +
+

DWS 可挖掘的 7 个额外场景

+
+

定位与创建链

  • +space-search:严格关键词搜索。
  • +space-get:空间详情与 workspaceId 证据。
  • +resolve-space:唯一命中直出 ID,多命中拒绝猜测。
  • +wiki-new-doc:空间名解析 → 创建 → 文档读回。
+

治理与巡检链

  • +member-update:角色变更后读回。
  • +node-search:库内关键词/扩展名搜索,严格零命中。
  • +feed-list:知识库动态时间线与服务端 exclude-file 过滤。
+
+
+ +
+

隐藏问题与修复

+ + + + + + + +
原问题错误风险本次修复
5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。Help、Schema、Skill 发现链与运行面漂移。20 项统一评审 全部具备 Contract/Safety/Result 与语义目录记录。
列表投影找不到数组或遇到坏元素时返回空 slice。把内部错误、字段漂移误报为“没有数据”。失败关闭 只有响应中真实存在的 [] 才是合法空集合。
节点列表 Shortcut 调错 Wiki MCP 服务。真实后端 success=false,Mock/静态检查看不出。跨域路由 明确调用 doc/list_nodes,并纳入真实 E2E。
成员帮助宣称最大 200。真实接口超过 50 直接参数错误。真实上限 Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。
知识库名称帮助宣称最大 100。真实接口超过 32 失败。真实上限 Help 与 Shortcut 校验统一为 32。
复制/移动/创建只把无异常视为成功。空确认、未知远端效果或移动未到目标仍可能被接受。读回证明 检查 success、业务 ID、workspace/folder/role 等最终状态。
+
+ +
+

真实数据 E2E 证据矩阵

+

测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。

+
+
+ + + + + + + + + + + + + + + + + + + + +
分组Shortcut实际业务断言状态
空间+space-list真实 count、hasMore、nextCursor;自动翻页返回两页结果。PASS
空间+space-search等待搜索索引后命中一次性 workspaceId。PASS
空间+space-get读回 workspaceId 与创建结果一致。PASS
空间+resolve-space唯一名称解析为同一 workspaceId。PASS
空间+space-createsuccess=true、workspaceId 非空、详情读回一致。PASS
空间+delete-space目标预读、确认、success=true;兼容别名执行 finally 清理。PASS
成员+member-list真实 owner 条目与显式 members 数组,limit=50。PASS
成员+member-add真实内部成员添加后在名单中出现且角色为 READER。PASS
成员+member-update同一成员读回角色变为 EDITOR。PASS
成员+member-remove移除后名单中不再存在该 userId。PASS
节点+node-list空库返回真实 nodes:[];有数据时验证游标与自动翻页。PASS
节点+node-get读回 nodeId 与请求一致。PASS
节点+node-search等待索引后按标题命中真实 nodeId。PASS
节点+node-create分别创建 folder/adoc,均取得 nodeId 和元数据读回。PASS
节点+node-copy取得不同的新 nodeId,副本元数据可读。PASS
节点+move读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。PASS
节点+move-to-drive移动后读回 workspace 发生变化,再通过 +move 移回。PASS
节点+node-delete目标预读与 workspace 核对后收到 success=true。PASS
节点+wiki-new-doc按唯一空间名创建,nodeId 与文档详情读回一致。PASS
动态+feed-list创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。PASS
+

可复跑入口:make build 后设置临时 DWS_WIKI_E2E_MEMBER_ID,运行 ./scripts/dev/wiki-shortcut-e2e.py。脚本只输出能力标签,不输出业务对象。

+
+ +
+

成功判定与发布门

+

运行时证据层

  1. 传输/MCP 调用成功。
  2. 响应契约存在且类型正确。
  3. 写操作必须有 success=true 与业务 ID。
  4. 创建/复制/移动/成员变更必须读回最终状态。
  5. 集合只有显式数组才允许为空。

交付门

  1. 20/20 语义目录与注册面精确覆盖。
  2. Contract、Safety、Result、统一输出完整。
  3. 生成漂移、Schema、确认真值、全量 Go 测试。
  4. 独立真实数据 E2E 与 finally 清理。
  5. diff PII/密钥/本地绝对路径扫描。
+
+ +
+ diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 55950aa0..0e1f6750 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 399 + publicShortcutCount = 418 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 401 + schemaPublishedShortcutCount = 420 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 399 + publiclyDeliveredShortcutCount = 418 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 88c0272e..3c8dc9cf 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command { }) // space create flags - spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)") + spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)") spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)") spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)") @@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command { Short: "查询知识库成员列表", Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。 -注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。 +注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。 若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围; ORG 类型授权不会出现在查询结果中。`, Example: ` dws wiki member list --workspace @@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`, limit, _ = cmd.Flags().GetInt("max-results") } if limit > 0 { + if limit > 50 { + return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页") + } toolArgs["maxResults"] = limit } if v := mustGetFlag(cmd, "filter-role"); v != "" { @@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`, }) memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200") + memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页") memberListCmd.Flags().Int("max-results", 0, "") _ = memberListCmd.Flags().MarkHidden("max-results") memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER") diff --git a/internal/shortcut/builtin/wiki_semantic_catalog_test.go b/internal/shortcut/builtin/wiki_semantic_catalog_test.go new file mode 100644 index 00000000..afe58c14 --- /dev/null +++ b/internal/shortcut/builtin/wiki_semantic_catalog_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_wiki.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "wiki" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "wiki" { + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + } + if len(registered) != 20 || len(source.Shortcuts) != 20 { + t.Fatalf("registered/catalog = %d/%d, want 20/20", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || item.Hidden || !record.Public { + t.Errorf("%s: not publicly reviewed", command) + } + if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition { + t.Errorf("%s: semantic facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: incomplete contract/safety/result/output", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 67a12d41..4ec9e1b7 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -404,6 +404,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "todo\u0000+overdue": {}, "todo\u0000+remind": {}, "todo\u0000+todo-done": {}, + "wiki\u0000+delete-space": {}, + "wiki\u0000+feed-list": {}, + "wiki\u0000+member-add": {}, + "wiki\u0000+member-list": {}, + "wiki\u0000+member-remove": {}, + "wiki\u0000+member-update": {}, + "wiki\u0000+move": {}, + "wiki\u0000+move-to-drive": {}, + "wiki\u0000+node-copy": {}, + "wiki\u0000+node-create": {}, + "wiki\u0000+node-delete": {}, + "wiki\u0000+node-get": {}, + "wiki\u0000+node-list": {}, + "wiki\u0000+node-search": {}, + "wiki\u0000+resolve-space": {}, + "wiki\u0000+space-create": {}, + "wiki\u0000+space-get": {}, + "wiki\u0000+space-list": {}, "wiki\u0000+space-search": {}, + "wiki\u0000+wiki-new-doc": {}, } } diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 96cb8593..6c04f845 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -25,6 +25,9 @@ var minutesSemanticCatalogJSON []byte //go:embed semantic_catalog_drive.json var driveSemanticCatalogJSON []byte +//go:embed semantic_catalog_wiki.json +var wikiSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -48,6 +51,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( aitableSemanticCatalogJSON, minutesSemanticCatalogJSON, driveSemanticCatalogJSON, + wikiSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_wiki.json b/internal/shortcut/semantic_catalog_wiki.json new file mode 100644 index 00000000..428438ee --- /dev/null +++ b/internal/shortcut/semantic_catalog_wiki.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "service": "wiki", + "default_availability": "available", + "shortcuts": { + "+space-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。","risk":"read","public":true,"reviewed":true}, + "+space-search": {"disposition":"semantic_adapter","semantic_delta":"按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。","risk":"read","public":true,"reviewed":true}, + "+resolve-space": {"disposition":"primary_smart","semantic_delta":"把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。","risk":"read","public":true,"reviewed":true}, + "+space-get": {"disposition":"schema_leaf","semantic_delta":"提供 Lark Shortcut 未单列的知识库详情入口,并要求 workspaceId 业务证据。","risk":"read","public":true,"reviewed":true}, + "+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true}, + "+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+member-add": {"disposition":"primary_smart","semantic_delta":"批量授予 Wiki 容器角色,严格校验写终态并读取成员列表验证响应链。","risk":"write","public":true,"reviewed":true}, + "+member-update": {"disposition":"primary_smart","semantic_delta":"补充 Lark Shortcut 缺失的成员角色更新,并执行终态与列表读回校验。","risk":"write","public":true,"reviewed":true}, + "+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true}, + "+member-remove": {"disposition":"primary_smart","semantic_delta":"批量移除容器访问,严格校验写终态并读取成员列表验证响应链。","risk":"write","public":true,"reviewed":true}, + + "+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true}, + "+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true}, + "+node-search": {"disposition":"semantic_adapter","semantic_delta":"补充 Lark Wiki Shortcut 缺失的库内关键词和扩展名搜索,并拒绝假空结果。","risk":"read","public":true,"reviewed":true}, + "+node-create": {"disposition":"primary_smart","semantic_delta":"支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。","risk":"write","public":true,"reviewed":true}, + "+node-copy": {"disposition":"primary_smart","semantic_delta":"高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。","risk":"high-risk-write","public":true,"reviewed":true}, + "+move": {"disposition":"primary_smart","semantic_delta":"同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。","risk":"write","public":true,"reviewed":true}, + "+move-to-drive": {"disposition":"primary_smart","semantic_delta":"同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。","risk":"write","public":true,"reviewed":true}, + "+node-delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+feed-list": {"disposition":"semantic_adapter","semantic_delta":"补充 Lark Shortcut 缺失的知识库协作动态查询,严格验证 feeds 并保留游标。","risk":"read","public":true,"reviewed":true}, + "+wiki-new-doc": {"disposition":"primary_smart","semantic_delta":"按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。","risk":"write","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/resolve_space.go b/internal/shortcut/smart/resolve_space.go index 496c13c8..0008151c 100644 --- a/internal/shortcut/smart/resolve_space.go +++ b/internal/shortcut/smart/resolve_space.go @@ -14,7 +14,13 @@ package smart import ( + "encoding/json" + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -35,15 +41,25 @@ import ( // // dws wiki +resolve-space --name 产品文档 var ResolveSpace = shortcut.Shortcut{ - Service: "wiki", - Command: "+resolve-space", - Product: "wiki", - Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", + OutputRollout: output.RolloutUnifiedActive, + Service: "wiki", + Command: "+resolve-space", + Product: "wiki", + Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" + "内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" + "如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + "这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。", - Risk: shortcut.RiskRead, + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, + Contract: corecmd.ContractDecl{ + Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)", + Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"知识库名称解析结果","properties":{"resolved":{"type":"boolean","description":"是否唯一解析"},"spaceId":{"type":"string","description":"唯一知识库 ID"},"name":{"type":"string","description":"唯一知识库名称"},"count":{"type":"integer","description":"候选数量"},"candidates":{"type":"array","description":"需要消歧的候选知识库","items":{"type":"object","description":"知识库候选","additionalProperties":true}}},"required":["resolved"],"additionalProperties":true}`)}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki resolver: the executable CLI strictly validates search results and refuses to guess when multiple spaces match."}, + Selection: contract.SelectionSpec{AgentSummary: "按名称搜索知识空间并解析出唯一 spaceId(只读)", UseWhen: []string{"当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。"}, AvoidWhen: []string{"只想浏览所有匹配项用 wiki +space-search;已知 workspaceId 时无需解析"}, Examples: []string{`dws wiki +resolve-space --name "产品文档"`}}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_resolve_space", CanonicalPath: "wiki.shortcut_resolve_space", CLIPath: "wiki +resolve-space", PrimaryCLIPath: "wiki +resolve-space"}, + Parameters: []contract.ParamDecl{{Name: "name", Property: "keyword"}}, + }, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true}, }, @@ -62,7 +78,10 @@ var ResolveSpace = shortcut.Shortcut{ } // Project candidates to {spaceId, name}, defensively unwrapping the list. - items := resolveSpaceItems(data) + items, err := resolveSpaceItemsStrict(data) + if err != nil { + return err + } candidates := make([]map[string]any, 0, len(items)) for _, s := range items { candidates = append(candidates, map[string]any{ @@ -123,7 +142,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any { // resolveSpaceID reads a space's identifier, tolerating the common id keys. func resolveSpaceID(s map[string]any) string { - for _, key := range []string{"spaceId", "space_id", "id"} { + for _, key := range []string{"workspaceId", "spaceId", "space_id", "id"} { if v, ok := s[key].(string); ok && v != "" { return v } @@ -131,6 +150,53 @@ func resolveSpaceID(s map[string]any) string { return "" } +func resolveSpaceItemsStrict(data map[string]any) ([]map[string]any, error) { + if len(data) == 0 { + return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response")) + } + if success, present := data["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure")) + } + } + containers := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if raw, present := data[wrapper]; present { + inner, ok := raw.(map[string]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 响应包装不是对象", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_envelope")) + } + containers = append(containers, inner) + } + } + for _, container := range containers { + for _, key := range []string{"wikiSpaces", "spaces", "items", "list", "records"} { + raw, present := container[key] + if !present { + continue + } + list, ok := raw.([]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection")) + } + out := make([]map[string]any, 0, len(list)) + for index, item := range list { + object, ok := item.(map[string]any) + if !ok { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) + } + if resolveSpaceID(object) == "" || resolveSpaceName(object) == "" { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) + } + out = append(out, object) + } + return out, nil + } + } + return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection")) +} + // resolveSpaceName reads a space's display name, tolerating the common name keys. func resolveSpaceName(s map[string]any) string { for _, key := range []string{"name", "spaceName", "title"} { diff --git a/internal/shortcut/smart/wiki_new_doc.go b/internal/shortcut/smart/wiki_new_doc.go index ebac09f6..85bde831 100644 --- a/internal/shortcut/smart/wiki_new_doc.go +++ b/internal/shortcut/smart/wiki_new_doc.go @@ -14,10 +14,14 @@ package smart import ( + "encoding/json" "fmt" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -33,15 +37,25 @@ import ( // // dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要" var WikiNewDoc = shortcut.Shortcut{ - Service: "wiki", - Command: "+wiki-new-doc", - Product: "wiki", - Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", + OutputRollout: output.RolloutUnifiedActive, + Service: "wiki", + Command: "+wiki-new-doc", + Product: "wiki", + Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" + "内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" + "如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" + "这会真实创建一个新的文档节点。", - Risk: shortcut.RiskWrite, + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"}, + Contract: corecmd.ContractDecl{ + Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", + Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的新建 Wiki 文档","properties":{"success":{"type":"boolean","description":"是否成功"},"nodeId":{"type":"string","description":"新文档节点 ID"},"space":{"type":"string","description":"请求的知识库名称"},"title":{"type":"string","description":"请求的文档标题"},"document":{"type":"object","description":"读回的文档元数据","additionalProperties":true}},"required":["success","nodeId","space","title","document"],"additionalProperties":true}`)}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki smart Shortcut: the executable CLI strictly resolves one exact space, creates a document, and verifies it through a metadata read-back."}, + Selection: contract.SelectionSpec{AgentSummary: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", UseWhen: []string{"当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。这会真实创建一个新的文档节点。"}, AvoidWhen: []string{"已知 workspaceId 时用 wiki +node-create;空间名不唯一时先用 wiki +space-search"}, Examples: []string{`dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`}}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_wiki_new_doc", CanonicalPath: "wiki.shortcut_wiki_new_doc", CLIPath: "wiki +wiki-new-doc", PrimaryCLIPath: "wiki +wiki-new-doc"}, + Parameters: []contract.ParamDecl{{Name: "space", Property: "keyword"}, {Name: "title", Property: "name"}}, + }, Flags: []shortcut.Flag{ {Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true}, {Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true}, @@ -88,7 +102,7 @@ var WikiNewDoc = shortcut.Shortcut{ "wouldCreateIn": workspaceID, }) } - created, err := rt.CallMCPWriteData("doc", "create_file", map[string]any{ + created, err := rt.CallMCPWriteDataStrict("doc", "create_file", map[string]any{ "workspaceId": workspaceID, "name": title, "type": "adoc", @@ -96,13 +110,28 @@ var WikiNewDoc = shortcut.Shortcut{ if err != nil { return err } - // Surface the created doc (id/url in the response) instead of returning - // silently — previously the caller got no confirmation of the new doc. + if success, ok := created["success"].(bool); !ok || !success { + return apperrors.NewAPI("create_file 未返回 success=true,无法证明文档已创建", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_terminal_success")) + } + nodeID := wikiNewDocFirstString(created, "nodeId", "fileId", "id") + if nodeID == "" { + return apperrors.NewAPI("create_file 未返回 nodeId,远端效果未知", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_created_id")) + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": nodeID}) + if err != nil { + return err + } + if success, present := verified["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return apperrors.NewAPI("新建文档读回未成功", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_failed")) + } + } + if wikiNewDocFirstString(verified, "nodeId", "fileId", "id") != nodeID { + return apperrors.NewAPI("新建文档读回 nodeId 不一致", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_id_mismatch")) + } return rt.Output(map[string]any{ - "created": true, - "space": spaceName, - "title": title, - "result": created, + "success": true, "nodeId": nodeID, "space": spaceName, "title": title, "document": verified, }) }, } @@ -117,7 +146,10 @@ type wikiSpaceCandidate struct { // out of a search_wikiSpaces response and returns its workspaceId. It errors // clearly when nothing matches or when the name is ambiguous, never guessing. func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) { - spaces := wikiNewDocExtractSpaces(data) + spaces, err := wikiNewDocExtractSpaces(data) + if err != nil { + return "", err + } if len(spaces) == 0 { return "", apperrors.NewValidation(fmt.Sprintf( "没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName)) @@ -156,9 +188,15 @@ func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, er // wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces // response may take into a list of {id, name} candidates. The gateway wraps the // list under one of several common container keys, so probe them defensively. -func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate { +func wikiNewDocExtractSpaces(data map[string]any) ([]wikiSpaceCandidate, error) { if data == nil { - return nil + return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response")) + } + if success, present := data["success"]; present { + value, ok := success.(bool) + if !ok || !value { + return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure")) + } } for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} { switch v := data[key].(type) { @@ -166,21 +204,25 @@ func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate { return wikiNewDocToCandidates(v) case map[string]any: for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} { - if arr, ok := v[k2].([]any); ok { + if raw, present := v[k2]; present { + arr, ok := raw.([]any) + if !ok { + return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection")) + } return wikiNewDocToCandidates(arr) } } } } - return nil + return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection")) } -func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate { +func wikiNewDocToCandidates(arr []any) ([]wikiSpaceCandidate, error) { out := make([]wikiSpaceCandidate, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) if !ok { - continue + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) } id := "" for _, k := range []string{"workspaceId", "spaceId", "id"} { @@ -196,12 +238,30 @@ func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate { break } } - if id == "" && name == "" { - continue + if id == "" || name == "" { + return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item")) } out = append(out, wikiSpaceCandidate{id: id, name: name}) } - return out + return out, nil +} + +func wikiNewDocFirstString(data map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for _, wrapper := range []string{"result", "data"} { + if inner, ok := data[wrapper].(map[string]any); ok { + for _, key := range keys { + if value, ok := inner[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + } + } + return "" } func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string { diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go new file mode 100644 index 00000000..1479315b --- /dev/null +++ b/internal/shortcut/wiki/common.go @@ -0,0 +1,274 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package wiki + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract." + +func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "wiki " + command + return corecmd.ContractDecl{ + Description: description, Parameters: params, Result: result, Pagination: pagination, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason}, + Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples}, + Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath}, + } +} + +func wikiObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))} +} + +func wikiCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`, + description, collection, description, collection))} +} + +func wikiCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath} +} + +func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功") + } + if value, present := data["success"]; present { + success, ok := value.(bool) + if !ok { + return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstWikiString(data, "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, wikiResponseError(operation, "remote_failure", message) + } + } + return data, nil +} + +func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, err + } + if success, ok := data["success"].(bool); !ok || !success { + return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据") + } + return data, nil +} + +func requireWikiObject(data map[string]any, operation string) (map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, err + } + for _, wrapper := range []string{"result", "data"} { + if value, present := data[wrapper]; present { + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形") + } + return object, nil + } + } + if len(data) == 1 { + return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象") + } + return data, nil +} + +func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) { + data, err := requireWikiResponse(data, operation) + if err != nil { + return nil, nil, err + } + containers := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if value, present := data[wrapper]; present { + inner, ok := value.(map[string]any) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper)) + } + containers = append(containers, inner) + } + } + for _, container := range containers { + for _, key := range keys { + value, present := container[key] + if !present { + continue + } + items, ok := value.([]any) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) + } + for index, item := range items { + if _, ok := item.(map[string]any); !ok { + return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index)) + } + } + return items, container, nil + } + } + return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果") +} + +func projectWikiRows(items []any, aliases map[string][]string) []map[string]any { + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + source := item.(map[string]any) + row := make(map[string]any) + for canonical, candidates := range aliases { + for _, candidate := range candidates { + if value, ok := source[candidate]; ok && value != nil { + row[canonical] = value + break + } + } + } + rows = append(rows, row) + } + return rows +} + +func addWikiPagination(out, page map[string]any) { + for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} { + if value, ok := page[pair[0]]; ok && value != nil { + out[pair[1]] = value + } + } +} + +func firstWikiString(data map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func nestedWikiString(data map[string]any, keys ...string) string { + if value := firstWikiString(data, keys...); value != "" { + return value + } + for _, wrapper := range []string{"result", "data"} { + if inner, ok := data[wrapper].(map[string]any); ok { + if value := firstWikiString(inner, keys...); value != "" { + return value + } + } + } + return "" +} + +func wikiResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason)) +} + +func wikiReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} +func wikiWriteSafety(confirm bool) contract.SafetySpec { + confirmation := "not_required" + if confirm { + confirmation = "user_required" + } + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"} +} +func wikiDeleteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func wikiAutoPageFlags() []shortcut.Flag { + const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0" + return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...) +} + +func enableWikiAutoPage(item *shortcut.Shortcut) { + item.Flags = append(item.Flags, wikiAutoPageFlags()...) + item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...) + item.Validate = func(rt *shortcut.RuntimeContext) error { + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return err + } + if rt.Bool("page-all") && rt.Int("page-limit") < 1 { + return fmt.Errorf("--page-limit 必须大于 0") + } + return nil + } +} + +type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error) + +func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) { + if !rt.Bool("page-all") { + data, err := fetch(rt.Str("cursor"), pageSize) + if err != nil { + return nil, nil, err + } + return requireWikiCollection(data, operation, keys...) + } + all := make([]any, 0) + cursor := rt.Str("cursor") + lastPage := map[string]any{} + for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ { + requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all)) + data, err := fetch(cursor, requestSize) + if err != nil { + return nil, nil, err + } + items, page, err := requireWikiCollection(data, operation, keys...) + if err != nil { + return nil, nil, err + } + all = append(all, items...) + lastPage = page + if maxItems := rt.Int("max-items"); maxItems > 0 && len(all) >= maxItems { + lastPage["autoPageComplete"] = false + lastPage["autoPageStopReason"] = "max_items" + lastPage["pagesFetched"] = pageNumber + return all, lastPage, nil + } + hasMore, present := page["hasMore"] + if !present { + return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值") + } + more, ok := hasMore.(bool) + if !ok { + return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值") + } + if !more { + lastPage["autoPageComplete"] = true + lastPage["pagesFetched"] = pageNumber + return all, lastPage, nil + } + next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken") + if next == "" { + return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标") + } + if next == cursor { + return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环") + } + cursor = next + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + return nil, nil, err + } + } + return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传") +} diff --git a/internal/shortcut/wiki/nodes.go b/internal/shortcut/wiki/nodes.go new file mode 100644 index 00000000..0655992c --- /dev/null +++ b/internal/shortcut/wiki/nodes.go @@ -0,0 +1,254 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package wiki + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", []shortcut.Flag{ + {Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true}, +}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"workspaceId": rt.Str("workspace"), "pageSize": size} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if cursor != "" { + params["pageToken"] = cursor + } + return rt.CallMCPData("doc", "list_nodes", params) + }) + if err != nil { + return err + } + nodes := projectWikiRows(items, nodeAliases()) + out := map[string]any{"count": len(nodes), "nodes": nodes} + addWikiPagination(out, page) + return rt.Output(out) +}) + +func nodeAliases() map[string][]string { + return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}} +} + +var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据;比 Lark Wiki 仅返回节点元数据的视角额外统一文档/文件属性。", "", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + object, err := requireWikiObject(data, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(object, "nodeId", "id", "fileId") == "" { + return wikiResponseError("doc/get_document_info", "missing_node_id", "节点详情缺少 nodeId") + } + return rt.Output(object) +}) + +var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")} + if rt.Changed("extensions") { + params["extensions"] = rt.StrSlice("extensions") + } + if rt.Changed("cursor") { + params["pageToken"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("doc", "search_documents", params) + if err != nil { + return err + } + items, page, err := requireWikiCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list") + if err != nil { + return err + } + nodes := projectWikiRows(items, nodeAliases()) + out := map[string]any{"count": len(nodes), "nodes": nodes} + addWikiPagination(out, page) + return rt.Output(out) +}) + +var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")} + if rt.Changed("folder") { + params["folderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/create_file", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "create_file", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "doc/create_file") + if err != nil { + return err + } + id := nestedWikiString(written, "nodeId", "fileId", "id") + if id == "" { + return wikiResponseError("doc/create_file", "missing_created_id", "创建响应没有 nodeId;远端效果未知") + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(verified, "nodeId", "id", "fileId") != id { + return wikiResponseError("doc/create_file", "readback_id_mismatch", "创建后读回节点 ID 不一致") + } + return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified}) +}) + +var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")} + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/copy_document", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "doc/copy_document") + if err != nil { + return err + } + id := nestedWikiString(written, "nodeId", "fileId", "id") + if id == "" { + return wikiResponseError("doc/copy_document", "missing_created_id", "复制响应没有新 nodeId;远端效果未知") + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified}) +}) + +func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error { + preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "doc/get_document_info") + if err != nil { + return err + } + beforeWorkspace := firstWikiString(preflight, "workspaceId", "spaceId") + params := map[string]any{"nodeId": rt.Str("node")} + if !toDrive { + params["workspaceId"] = rt.Str("workspace") + } + if rt.Changed("folder") { + params["targetFolderId"] = rt.Str("folder") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/move_document", "arguments": params, "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params) + if err != nil { + return err + } + if _, err = requireWikiWrite(written, "doc/move_document"); err != nil { + return err + } + verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "doc/get_document_info") + if err != nil { + return err + } + if firstWikiString(verified, "nodeId", "id", "fileId") != rt.Str("node") { + return wikiResponseError("doc/move_document", "readback_id_mismatch", "移动后读回节点 ID 不一致") + } + afterWorkspace := firstWikiString(verified, "workspaceId", "spaceId") + if toDrive { + if beforeWorkspace == "" || afterWorkspace == "" || beforeWorkspace == afterWorkspace { + return wikiResponseError("doc/move_document", "drive_move_not_verified", "移动到我的文档后 workspace 未发生可验证变化") + } + } else if afterWorkspace != rt.Str("workspace") { + return wikiResponseError("doc/move_document", "workspace_readback_mismatch", "移动后读回的目标知识库不一致") + } + if rt.Changed("folder") && firstWikiString(verified, "folderId", "parentId") != rt.Str("folder") { + return wikiResponseError("doc/move_document", "folder_readback_mismatch", "移动后读回的目标文件夹不一致") + } + return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified}) +} + +var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同时覆盖 Lark 的 Wiki 内移动与 Drive 文档入 Wiki 场景。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) }) + +var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回替代 Lark 异步任务轮询。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) }) + +var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error { + preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "doc/get_document_info") + if err != nil { + return err + } + if workspace := firstWikiString(preflight, "workspaceId", "spaceId"); workspace != "" && workspace != rt.Str("workspace") { + return wikiResponseError("doc/delete_document", "workspace_preflight_mismatch", "节点不属于请求确认的知识库") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/delete_document", "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")}) + if err != nil { + return err + } + if _, err = requireWikiWrite(written, "doc/delete_document"); err != nil { + return err + } + return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true}) +}) + +var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error { + items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size} + if cursor != "" { + params["nextToken"] = cursor + } + if rt.Changed("exclude-file") { + params["excludeFile"] = rt.Bool("exclude-file") + } + return rt.CallMCPData("wiki", "list_workspace_feeds", params) + }) + if err != nil { + return err + } + feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}}) + out := map[string]any{"count": len(feeds), "feeds": feeds} + addWikiPagination(out, page) + return rt.Output(out) +}) + +func init() { + Move.Aliases = []string{"+node-move"} + for _, item := range []*shortcut.Shortcut{&NodeList, &FeedList} { + enableWikiAutoPage(item) + } + for _, item := range []*shortcut.Shortcut{&NodeList, &NodeSearch, &FeedList} { + item.Contract.Pagination = wikiCursorPagination() + } + shortcut.Register(NodeList, NodeGet, NodeSearch, NodeCreate, NodeCopy, Move, MoveToDrive, NodeDelete, FeedList) + _ = fmt.Sprintf + _ = output.RolloutUnifiedActive +} diff --git a/internal/shortcut/wiki/space_list_projection_test.go b/internal/shortcut/wiki/space_list_projection_test.go index da5602f0..69648bf2 100644 --- a/internal/shortcut/wiki/space_list_projection_test.go +++ b/internal/shortcut/wiki/space_list_projection_test.go @@ -18,11 +18,11 @@ import ( "testing" ) -// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss: +// TestCrossPlatformCoverageWikiSpaceListShape guards against projection-data-loss: // list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces; // the resolver must probe "wikiSpaces" or +space-list / +space-search silently // return empty despite the backend returning spaces. -func TestSpaceListProjectWikiSpacesShape(t *testing.T) { +func TestCrossPlatformCoverageWikiSpaceListShape(t *testing.T) { const raw = `{"result":{"hasMore":false,"wikiSpaces":[ {"workspaceId":"w1","name":"R&D wiki"}, {"workspaceId":"w2","name":"product wiki"} @@ -31,19 +31,47 @@ func TestSpaceListProjectWikiSpacesShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - if spaces := spaceListProject(data); len(spaces) != 2 { + items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil { + t.Fatal(err) + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}}) + if len(spaces) != 2 { t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces) } } // TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape. -func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) { +func TestCrossPlatformCoverageWikiSpaceListTopLevelShape(t *testing.T) { const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - if spaces := spaceListProject(data); len(spaces) != 1 { + items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil { + t.Fatal(err) + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}}) + if len(spaces) != 1 { t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces) } } + +func TestCrossPlatformCoverageWikiCollectionsRejectFalseEmptySuccess(t *testing.T) { + for name, data := range map[string]map[string]any{ + "missing": {"success": true, "hasMore": false}, + "malformed": {"success": true, "wikiSpaces": map[string]any{}}, + "bad item": {"success": true, "wikiSpaces": []any{"not-an-object"}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces"); err == nil { + t.Fatal("malformed response was accepted as an empty success") + } + }) + } + items, _, err := requireWikiCollection(map[string]any{"success": true, "wikiSpaces": []any{}}, "wiki/list_wikiSpaces", "wikiSpaces") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty array must remain valid: items=%v err=%v", items, err) + } +} diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go index 4e8d0990..28012ba8 100644 --- a/internal/shortcut/wiki/wiki.go +++ b/internal/shortcut/wiki/wiki.go @@ -1,344 +1,249 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package wiki declares high-fidelity shortcuts for the DingTalk wiki -// (knowledge base) service: space management, member management and node -// management. Tool names and parameters mirror internal/helpers/wiki.go. +// Package wiki declares reviewed, truth-preserving shortcuts for Wiki spaces, +// members, nodes, and activity feeds. package wiki import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// ── space (知识库) ──────────────────────────────────────────── +var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"} -// SpaceCreate → create_wikiSpace -// SpaceGet → get_wikiSpace -// SpaceList → list_wikiSpaces -var SpaceList = shortcut.Shortcut{ - Service: "wiki", - Command: "+space-list", - Product: "wiki", - Description: "列出组织 / 个人知识库", - Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"}, - {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"}, - }, - Tips: []string{ - `dws wiki +space-list`, - `dws wiki +space-list --type myWikiSpace`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{} - if rt.Changed("type") { - params["wikiSpaceType"] = rt.Str("type") +func readShortcut(command, description, intent, collection string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + result := wikiObjectResult(description) + if collection != "" { + result = wikiCollectionResult(collection, description) + } + return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{"dws wiki " + command + " --format json"}, result, nil, params...), Flags: flags, Execute: execute} +} + +func writeShortcut(command, description, intent string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{"dws wiki " + command + " --format json"}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute} +} + +var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true}, +}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error { + items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", rt.Int("limit"), []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) { + params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size} + if cursor != "" { + params["pageToken"] = cursor } - if rt.Changed("limit") { - params["pageSize"] = rt.Str("limit") + return rt.CallMCPData("wiki", "list_wikiSpaces", params) + }) + if err != nil { + return err + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "createTime": {"createTime", "createdAt"}, "url": {"spaceUrl", "url"}}) + out := map[string]any{"count": len(spaces), "spaces": spaces} + addWikiPagination(out, page) + return rt.Output(out) +}) + +var SpaceSearch = readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "返回数量 1-20"}, +}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "pageSize"}}, func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": rt.Int("limit")}) + if err != nil { + return err + } + items, page, err := requireWikiCollection(data, "wiki/search_wikiSpaces", "wikiSpaces", "spaces") + if err != nil { + return err + } + spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "url": {"spaceUrl", "url"}}) + out := map[string]any{"count": len(spaces), "spaces": spaces} + addWikiPagination(out, page) + return rt.Output(out) +}) + +var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + object, err := requireWikiObject(data, "wiki/get_wikiSpace") + if err != nil { + return err + } + if firstWikiString(object, "workspaceId", "spaceId", "id") == "" { + return wikiResponseError("wiki/get_wikiSpace", "missing_workspace_id", "空间详情缺少 workspaceId") + } + return rt.Output(object) +}) + +var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"name": rt.Str("name")} + if rt.Changed("desc") { + params["description"] = rt.Str("desc") + } + if rt.Changed("icon") { + params["icon"] = rt.Str("icon") + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/create_wikiSpace", "arguments": params}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", "create_wikiSpace", params) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "wiki/create_wikiSpace") + if err != nil { + return err + } + id := nestedWikiString(written, "workspaceId", "spaceId", "id") + if id == "" { + return wikiResponseError("wiki/create_wikiSpace", "missing_created_id", "创建响应没有 workspaceId;远端效果未知") + } + verified, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": id}) + if err != nil { + return err + } + verified, err = requireWikiObject(verified, "wiki/get_wikiSpace") + if err != nil { + return err + } + if firstWikiString(verified, "workspaceId", "spaceId", "id") != id { + return wikiResponseError("wiki/create_wikiSpace", "readback_id_mismatch", "创建后读回的 workspaceId 不一致") + } + return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified}) +}) + +var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error { + preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + preflight, err = requireWikiObject(preflight, "wiki/get_wikiSpace") + if err != nil { + return err + } + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/delete_wikiSpace", "target": preflight}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", "delete_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")}) + if err != nil { + return err + } + written, err = requireWikiWrite(written, "wiki/delete_wikiSpace") + if err != nil { + return err + } + return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "deleted": true}) +}) + +func memberFlags(withRole bool) []shortcut.Flag { + flags := []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "用户 userId,最多 30 个", Aliases: []string{"user"}, AliasesVisible: true}} + if withRole { + flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Required: true, Desc: "角色", Enum: []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}}) + } + return flags +} + +func memberWrite(command, tool, description, intent string, withRole bool) shortcut.Shortcut { + params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}} + if withRole { + params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"}) + } + return writeShortcut(command, description, intent, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error { + users := rt.StrSlice("users") + if len(users) == 0 || len(users) > 30 { + return fmt.Errorf("--users 必须包含 1-30 个 userId") } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") + args := map[string]any{"workspaceId": rt.Str("workspace"), "userIds": users} + if withRole { + args["roleId"] = strings.ToUpper(rt.Str("role")) } - data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params) + if rt.DryRun() { + return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/" + tool, "arguments": args}) + } + written, err := rt.CallMCPWriteDataStrict("wiki", tool, args) if err != nil { return err } - spaces := spaceListProject(data) - return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) - }, -} - -// spaceListProject reshapes list_wikiSpaces into a clean space list -// ({workspaceId, name, description, createTime}) — output-projection fidelity -// for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unrecognized shape yields an empty list. -func spaceListProject(data map[string]any) []map[string]any { - raw := wikiSpaceRawList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue + written, err = requireWikiWrite(written, "wiki/"+tool) + if err != nil { + return err } - row := map[string]any{} - if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil { - row["workspaceId"] = v + listed, err := rt.CallMCPData("wiki", "list_member", map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": 50}) + if err != nil { + return err } - if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil { - row["name"] = v + items, _, err := requireWikiCollection(listed, "wiki/list_member", "members") + if err != nil { + return err } - if v := wikiSpaceFirst(m, "description", "desc"); v != nil { - row["description"] = v - } - if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil { - row["createTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// wikiSpaceRawList locates the space array across candidate container keys, -// tolerating a nested {result|data:{list|items|spaces}} wrapper. -func wikiSpaceRawList(data map[string]any) []any { - // list_wikiSpaces / search_wikiSpaces nest the space list under - // result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces" - // MUST be probed or +space-list / +space-search silently return empty. - for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} { - if arr, ok := data[k].([]any); ok { - return arr - } - if inner, ok := data[k].(map[string]any); ok { - for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } + found := make(map[string]string) + for _, item := range items { + member := item.(map[string]any) + id := firstWikiString(member, "id", "userId") + if id != "" { + found[id] = strings.ToUpper(firstWikiString(member, "role", "roleId")) } } - } - return nil -} - -// wikiSpaceFirst returns the first present value among candidate keys. -func wikiSpaceFirst(m map[string]any, keys ...string) any { - for _, k := range keys { - if v, ok := m[k]; ok { - return v - } - } - return nil -} - -// SpaceSearch → search_wikiSpaces -var SpaceSearch = shortcut.Shortcut{ - Service: "wiki", - Command: "+space-search", - Product: "wiki", - Description: "搜索知识库", - Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "wiki", - Name: "shortcut_space_search", - CanonicalPath: "wiki.shortcut_space_search", - CLIPath: "wiki +space-search", - PrimaryCLIPath: "wiki +space-search", - }, - Description: "搜索知识库", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "搜索知识库", - UseWhen: []string{"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws wiki +space-search --query \"产品文档\""}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"}, - }, - Tips: []string{`dws wiki +space-search --query "产品文档"`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"keyword": rt.Str("query")} - if rt.Changed("limit") { - params["pageSize"] = rt.Str("limit") - } - data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params) - if err != nil { - return err - } - spaces := spaceListProject(data) - return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces}) - }, -} - -// SpaceDelete → delete_wikiSpace -// ── member (知识库成员) ─────────────────────────────────────── - -// MemberAdd → add_member -// MemberUpdate → update_member -// MemberList → list_member -// MemberRemove → remove_member -// ── node (知识库节点,路由到 doc MCP server) ────────────────── - -// NodeList → list_nodes (doc) -var NodeList = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-list", - Product: "doc", - Description: "列出知识库节点", - Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, - {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, - }, - Tips: []string{`dws wiki +node-list --workspace --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"workspaceId": rt.Str("workspace")} - if rt.Changed("folder") { - params["folderId"] = rt.Str("folder") - } - if rt.Changed("limit") { - params["pageSize"] = rt.Int("limit") - } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") - } - data, err := rt.CallMCPData("wiki", "list_nodes", params) - if err != nil { - return err - } - nodes := nodeListProject(data) - return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes}) - }, -} - -// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type) -// — clean output projection. Container and field keys are probed -// defensively across candidate aliases; an unrecognized shape yields an empty list. -func nodeListProject(data map[string]any) []map[string]any { - raw := nodeListRawList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil { - row["name"] = v - } - if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil { - row["nodeId"] = v - } - if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil { - row["type"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// nodeListRawList locates the node array across candidate container keys, -// tolerating a nested {result|data:{list|items|nodes}} wrapper. -func nodeListRawList(data map[string]any) []any { - for _, k := range []string{"result", "data", "list", "items", "nodes"} { - if arr, ok := data[k].([]any); ok { - return arr - } - if inner, ok := data[k].(map[string]any); ok { - for _, ik := range []string{"list", "items", "nodes", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } + for _, user := range users { + role, present := found[user] + if tool == "remove_member" && present { + return wikiResponseError("wiki/"+tool, "readback_member_still_present", "成员移除后仍出现在成员列表中") + } + if tool != "remove_member" && !present { + return wikiResponseError("wiki/"+tool, "readback_member_missing", "成员写入后未出现在成员列表中") + } + if withRole && role != strings.ToUpper(rt.Str("role")) { + return wikiResponseError("wiki/"+tool, "readback_role_mismatch", "成员写入后读回角色与请求不一致") } } + return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool, "verifiedBy": "member_list_readback"}) + }) +} + +var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色,并用成员列表读回验证响应契约。", true) +var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色,并用成员列表读回验证响应契约。", true) +var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问,并用成员列表读回验证响应契约。", false) + +var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error { + if rt.Int("limit") < 1 || rt.Int("limit") > 50 { + return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页") } - return nil -} - -// nodeListFirst returns the first present value among candidate keys. -func nodeListFirst(m map[string]any, keys ...string) any { - for _, k := range keys { - if v, ok := m[k]; ok { - return v - } + params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": rt.Int("limit")} + if rt.Changed("filter-role") { + params["filterRoleIds"] = rt.StrSlice("filter-role") } - return nil -} + data, err := rt.CallMCPData("wiki", "list_member", params) + if err != nil { + return err + } + items, page, err := requireWikiCollection(data, "wiki/list_member", "members") + if err != nil { + return err + } + members := projectWikiRows(items, map[string][]string{"id": {"id", "userId"}, "name": {"name", "nick"}, "role": {"role", "roleId"}, "type": {"type"}, "outer": {"outer"}}) + out := map[string]any{"count": len(members), "members": members} + addWikiPagination(out, page) + return rt.Output(out) +}) -// NodeCreate → create_file (doc) -// NodeCopy → copy_document (doc) -var NodeCopy = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-copy", - Product: "doc", - Description: "复制知识库节点", - Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"}, - }, - Tips: []string{`dws wiki +node-copy --workspace --node --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "workspaceId": rt.Str("workspace"), - } - if rt.Changed("folder") { - params["targetFolderId"] = rt.Str("folder") - } - return rt.CallMCP("copy_document", params) - }, -} - -// NodeMove → move_document (doc) -var NodeMove = shortcut.Shortcut{ - Service: "wiki", - Command: "+node-move", - Product: "doc", - Description: "移动知识库节点", - Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true}, - {Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"}, - }, - Tips: []string{`dws wiki +node-move --workspace --node --folder `}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "workspaceId": rt.Str("workspace"), - } - if rt.Changed("folder") { - params["targetFolderId"] = rt.Str("folder") - } - return rt.CallMCP("move_document", params) - }, -} - -// NodeDelete → delete_document (doc) -// NodeSearch → search_documents (doc) func init() { - shortcut.Register( - SpaceList, - SpaceSearch, - NodeList, - NodeCopy, - NodeMove, - ) + DeleteSpace.Aliases = []string{"+space-delete"} + SpaceCreate.Validate = func(rt *shortcut.RuntimeContext) error { + if len([]rune(rt.Str("name"))) > 32 { + return fmt.Errorf("--name 不能超过 32 个字符") + } + if len([]rune(rt.Str("desc"))) > 500 { + return fmt.Errorf("--desc 不能超过 500 个字符") + } + return nil + } + SpaceCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"name", "desc"}, Description: "--name 不超过 32 个字符;--desc 不超过 500 个字符"}} + enableWikiAutoPage(&SpaceList) + SpaceList.Contract.Pagination = wikiCursorPagination() + shortcut.Register(SpaceList, SpaceSearch, SpaceGet, SpaceCreate, DeleteSpace, MemberAdd, MemberUpdate, MemberList, MemberRemove) } diff --git a/scripts/dev/wiki-shortcut-e2e.py b/scripts/dev/wiki-shortcut-e2e.py new file mode 100755 index 00000000..84ecd391 --- /dev/null +++ b/scripts/dev/wiki-shortcut-e2e.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Run destructive, real-data Wiki Shortcut verification on a disposable space. + +Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real +internal user ID that may be granted temporary access to the empty fixture. +The script prints capability labels only; business IDs, names, URLs, and raw +responses remain in memory. The disposable Wiki space is deleted in `finally`. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DWS = ROOT / "dws" +MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip() + + +class E2EFailure(RuntimeError): + pass + + +def invoke(args: list[str]) -> dict: + process = subprocess.run( + [str(DWS), "wiki", *args, "--format", "json"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + try: + envelope = json.loads(process.stdout) + except json.JSONDecodeError as exc: + raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc + if process.returncode != 0 or envelope.get("ok") is not True: + error = envelope.get("error") or {} + reason = error.get("reason") or error.get("category") or "command_failed" + raise E2EFailure(f"{reason} (exit {process.returncode})") + data = envelope.get("data") + if not isinstance(data, dict): + raise E2EFailure("success envelope lacks object data") + return data + + +def check(label: str, condition: bool) -> None: + if not condition: + raise E2EFailure(f"{label}: business assertion failed") + print(f"PASS {label}") + + +def main() -> int: + if not DWS.exists(): + raise E2EFailure("build ./dws first with make build") + if not MEMBER_ID: + raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member") + + stamp = time.strftime("%m%d%H%M%S") + space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters + workspace = "" + disposable_nodes: list[str] = [] + member_added = False + try: + created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"]) + workspace = str(created.get("workspaceId") or "") + check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace) + + page = invoke(["+space-list", "--limit", "1"]) + check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool)) + all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"]) + check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2) + search_ok = False + for _ in range(8): + searched = invoke(["+space-search", "--query", space_name]) + if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])): + search_ok = True + break + time.sleep(2) + check("space-search", search_ok) + detail = invoke(["+space-get", "--workspace", workspace]) + check("space-get", detail.get("workspaceId") == workspace) + resolved = invoke(["+resolve-space", "--name", space_name]) + check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace) + + empty_nodes = invoke(["+node-list", "--workspace", workspace]) + check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == []) + + folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"]) + folder_id = str(folder.get("nodeId") or "") + disposable_nodes.append(folder_id) + check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id) + document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"]) + node_id = str(document.get("nodeId") or "") + disposable_nodes.append(node_id) + check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id) + + listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"]) + check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor"))) + all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"]) + check( + "node-list-auto-page", + all_nodes.get("count", 0) >= 1 + and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False), + ) + info = invoke(["+node-get", "--node", node_id]) + check("node-get", info.get("nodeId") == node_id) + + # Search indexing can lag after a create; retry without accepting a + # malformed/missing collection as an empty success. + search_ok = False + for _ in range(6): + found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"]) + if any(row.get("nodeId") == node_id for row in found.get("nodes", [])): + search_ok = True + break + time.sleep(2) + check("node-search", search_ok) + + copied = invoke(["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"]) + copy_id = str(copied.get("nodeId") or "") + disposable_nodes.append(copy_id) + check("node-copy-readback", bool(copy_id) and bool(copied.get("copy"))) + + moved = invoke(["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"]) + check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id) + moved_out = invoke(["+move-to-drive", "--node", node_id, "--yes"]) + check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace) + moved_back = invoke(["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"]) + check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace) + + named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"]) + named_id = str(named.get("nodeId") or "") + disposable_nodes.append(named_id) + check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id) + + members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) + check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list)) + added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"]) + member_added = True + check("member-add-readback", added.get("success") is True and added.get("verifiedBy") == "member_list_readback") + updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"]) + check("member-update-readback", updated.get("success") is True and updated.get("verifiedBy") == "member_list_readback") + removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) + member_added = False + check("member-remove-readback", removed.get("success") is True and removed.get("verifiedBy") == "member_list_readback") + + feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"]) + check("feed-list", isinstance(feeds.get("feeds"), list)) + + # Exercise high-risk delete before final whole-space cleanup. + delete_target = disposable_nodes.pop() + deleted = invoke(["+node-delete", "--workspace", workspace, "--node", delete_target, "--yes"]) + check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True) + return 0 + finally: + if workspace: + if member_added: + try: + invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) + except Exception: + pass + try: + deleted = invoke(["+space-delete", "--workspace", workspace, "--yes"]) + check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True) + except Exception as exc: + print(f"CLEANUP FAILED: {exc}", file=sys.stderr) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except E2EFailure as exc: + print(f"FAIL {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 4528688b..2bdd2820 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -24,6 +24,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json", ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json", ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ] diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 3d916175..210215b5 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -58,7 +58,7 @@ cli_version: ">=1.0.15" | `report` | 2 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 11 | `dingtalk-todo` | -| `wiki` | 1 | `dingtalk-wiki` | +| `wiki` | 20 | `dingtalk-wiki` | ## 多组织 / 多账号 diff --git a/skills/multi/dingtalk-wiki/SKILL.md b/skills/multi/dingtalk-wiki/SKILL.md index a3dc5da7..320f4f76 100644 --- a/skills/multi/dingtalk-wiki/SKILL.md +++ b/skills/multi/dingtalk-wiki/SKILL.md @@ -24,7 +24,8 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws wiki +space-search` | read | 搜索知识库 | +| `dws wiki +resolve-space` | read | 按名称搜索知识空间并解析出唯一 spaceId(只读) | +| `dws wiki +wiki-new-doc` | write | 在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId) | ## 意图表