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 Shortcut | DWS 主入口 | 结论 | DWS 视角与边界 |
+ +space-list | wiki +space-list | 完整对齐 | 严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。 |
+ +space-create | wiki +space-create | 超过 | 公开真实 32 字符名称上限;创建后按 workspaceId 读回。 |
+ +delete-space | wiki +delete-space | 超过 | 预读目标、高风险确认、只接受 success=true;兼容 +space-delete。 |
+ +member-add | wiki +member-add | 任务对齐 | 支持 1–30 个 userId 与四种角色,读回名单和角色;不支持 email/open_id 等身份类型。 |
+ +member-list | wiki +member-list | 任务对齐 | 严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。 |
+ +member-remove | wiki +member-remove | 任务对齐 | 批量 userId,移除后读回确认成员已消失;身份类型边界同上。 |
+ +node-list | wiki +node-list | 完整对齐 | 正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。 |
+ +node-get | wiki +node-get | 任务对齐 | 支持 DingTalk 节点 ID/在线文档 URL并返回文档域元数据;不接受 Lark 特有 token/type 组合。 |
+ +node-create | wiki +node-create | 任务对齐 | 支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。 |
+ +node-copy | wiki +node-copy | 超过 | 确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。 |
+ +move | wiki +move | 任务对齐 | 同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;无 Lark apply 权限迁移开关。 |
+ +move-to-drive | wiki +move-to-drive | 超过 | DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。 |
+ +node-delete | wiki +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-create | success=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。脚本只输出能力标签,不输出业务对象。
+
+
+
+ 成功判定与发布门
+ 运行时证据层
- 传输/MCP 调用成功。
- 响应契约存在且类型正确。
- 写操作必须有
success=true 与业务 ID。 - 创建/复制/移动/成员变更必须读回最终状态。
- 集合只有显式数组才允许为空。
交付门
- 20/20 语义目录与注册面精确覆盖。
- Contract、Safety、Result、统一输出完整。
- 生成漂移、Schema、确认真值、全量 Go 测试。
- 独立真实数据 E2E 与 finally 清理。
- 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) |
## 意图表