Files
dingtalk-workspace-cli/internal/helpers/drive.go
T

4243 lines
177 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws drive — 钉盘
// MCP tools: list_files, get_file_info, download_file, create_folder,
// get_upload_info, commit_upload
// ──────────────────────────────────────────────────────────
func driveRenameBaseName(name, nodeType, currentExtension string) string {
trimmed := strings.TrimSpace(name)
switch strings.ToLower(strings.TrimSpace(nodeType)) {
case "folder", "dir", "directory":
return trimmed
}
extension := strings.TrimLeft(strings.TrimSpace(currentExtension), ".")
if extension == "" {
return trimmed
}
suffix := "." + extension
if len(trimmed) <= len(suffix) || !strings.EqualFold(trimmed[len(trimmed)-len(suffix):], suffix) {
return trimmed
}
return trimmed[:len(trimmed)-len(suffix)]
}
func resolveDriveRenameName(ctx context.Context, nodeID, name string) (string, error) {
fileID := nodeID
if parsedNodeID := extractNodeIDFromDocURL(nodeID); parsedNodeID != "" {
fileID = parsedNodeID
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{
"fileId": fileID,
})
if err != nil {
return "", fmt.Errorf("无法读取节点元数据,未执行重命名: %w", err)
}
var response map[string]any
if err := json.Unmarshal([]byte(text), &response); err != nil {
return "", fmt.Errorf("无法解析节点元数据,未执行重命名: %w", err)
}
result, ok := response["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("节点元数据缺少 result,未执行重命名")
}
return driveRenameBaseName(
name,
firstStringField(result, "type", "nodeType", "fileType"),
firstStringField(result, "extension", "fileExtension", "ext"),
), nil
}
func runDriveUpload(cmd *cobra.Command, _ []string) error {
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fi, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fi.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName := flagOrFallback(cmd, "file-name", "name")
if fileName == "" {
fileName = filepath.Base(filePath)
}
fileSize := fi.Size()
// --node switches the upload from create mode to overwrite mode.
overwriteNodeID := flagOrFallback(cmd, "node", "node-id", "file-id", "doc-id")
parentID := docFolderFlag(cmd)
if overwriteNodeID != "" && parentID != "" {
return fmt.Errorf("--node 与 --folder 互斥:--node 用于覆盖已有文件,--folder 用于上传到目录,不可同时指定")
}
// 路由判断:--workspace 存在时走文档空间上传流程
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
spaceID, _ := cmd.Flags().GetString("space-id")
if workspaceID != "" && spaceID != "" {
return fmt.Errorf("--space-id 与 --workspace 互斥:请只指定钉盘空间或知识库中的一个目标域")
}
if workspaceID != "" {
return runDriveUploadToDocSpace(cmd, filePath, fileName, fileSize, workspaceID)
}
if overwriteNodeID == "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
}
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "upload",
"source": "drive",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"space_id": spaceID,
"folder_id": parentID,
"node_id": overwriteNodeID,
})
}
deps.Out.PrintKeyValue("操作", "上传文件到钉盘")
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
if overwriteNodeID != "" {
deps.Out.PrintKeyValue("覆盖目标", overwriteNodeID)
}
return nil
}
if overwriteNodeID != "" && !confirmDangerousAction(cmd, "overwrite drive file", overwriteNodeID) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
mimeType, _ := cmd.Flags().GetString("mime-type")
return uploadToDrive(ctx, filePath, fileName, fileSize, spaceID, parentID, overwriteNodeID, mimeType)
}
// runDriveUploadToDocSpace 处理文档空间上传流程(当 --workspace 存在时路由到此)。
// 使用 doc MCP server 的 get_file_upload_info + commit_uploaded_file 工具。
func runDriveUploadToDocSpace(cmd *cobra.Command, filePath, fileName string, fileSize int64, workspaceID string) error {
overwriteNodeID := flagOrFallback(cmd, "node", "node-id", "file-id", "doc-id")
folder := docFolderFlag(cmd)
if overwriteNodeID == "" {
if err := validateDocFolderID(folder); err != nil {
return err
}
}
// 补全文件名后缀
if filepath.Ext(fileName) == "" {
if ext := filepath.Ext(filePath); ext != "" {
fileName += ext
}
}
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "upload",
"source": "doc",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"workspace_id": workspaceID,
"folder_id": folder,
"node_id": overwriteNodeID,
})
}
deps.Out.PrintKeyValue("操作", "上传文件到文档空间")
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
deps.Out.PrintKeyValue("知识库", workspaceID)
if overwriteNodeID != "" {
deps.Out.PrintKeyValue("覆盖目标", overwriteNodeID)
}
return nil
}
if overwriteNodeID != "" && !confirmDangerousAction(cmd, "overwrite document-space file", overwriteNodeID) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
convert, _ := cmd.Flags().GetBool("convert")
return uploadToDocSpace(ctx, filePath, fileName, fileSize, workspaceID, folder, overwriteNodeID, convert)
}
func validateDriveParentID(parentID string) error {
value := strings.TrimSpace(parentID)
if value == "" {
return nil
}
for _, r := range value {
if r < '0' || r > '9' {
return nil
}
}
return fmt.Errorf("invalid drive --folder %q: pure numeric IDs are usually dentryId values for chat --dentry-id, not drive parent dentryUuid values; use a parent folder dentryUuid from drive list or omit --folder to use the space root", parentID)
}
// parseDriveUploadInfo extracts the upload URL, uploadId and headers from the
// drive MCP tool response. The actual response format is:
//
// {
// "uploadId": "...",
// "resourceUrls": [
// { "url": "https://...", "headers": { ... } }
// ]
// }
func parseDriveUploadInfo(text string) (resourceURL, uploadID string, headers map[string]string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
err = fmt.Errorf("failed to parse drive upload credentials JSON: %w", err)
return
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
uploadID, _ = data["uploadId"].(string)
// Extract URL from resourceUrls array (primary format)
if urls, ok := data["resourceUrls"].([]any); ok && len(urls) > 0 {
if first, ok := urls[0].(map[string]any); ok {
resourceURL, _ = first["url"].(string)
// Extract per-URL headers
headers = make(map[string]string)
if h, ok := first["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
}
// Fallback: try flat resourceUrl / uploadUrl fields
if resourceURL == "" {
resourceURL, _ = data["resourceUrl"].(string)
}
if resourceURL == "" {
resourceURL, _ = data["uploadUrl"].(string)
}
if resourceURL == "" || uploadID == "" {
err = fmt.Errorf("incomplete drive upload credentials: resourceUrl=%q, uploadId=%q", resourceURL, uploadID)
return
}
// Fallback: top-level headers (if per-URL headers were empty)
if headers == nil {
headers = make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
return
}
// DriveUploadRequest describes the reusable Drive upload transaction used by
// the native leaf and the curated +upload shortcut. FilePath must already be
// resolved and validated by the caller.
type DriveUploadRequest struct {
FilePath string
FileName string
FileSize int64
SpaceID string
ParentID string
OverwriteFile string
MIMEType string
}
// UploadDriveFileData runs credentials -> OSS PUT -> commit exactly once and
// returns the parsed commit response without rendering it. Unlike the legacy
// leaf helper, this path fails when the commit has no non-empty JSON response;
// the Shortcut can then require terminal success evidence and read back the
// created node before reporting success.
func UploadDriveFileData(ctx context.Context, request DriveUploadRequest) (map[string]any, error) {
if strings.TrimSpace(request.FilePath) == "" || strings.TrimSpace(request.FileName) == "" || request.FileSize <= 0 {
return nil, fmt.Errorf("invalid drive upload request")
}
step1Args := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
}
if request.SpaceID != "" {
step1Args["spaceId"] = request.SpaceID
}
if request.MIMEType != "" {
step1Args["mimeType"] = request.MIMEType
}
if request.OverwriteFile != "" {
step1Args["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
step1Args["parentId"] = request.ParentID
}
credentialText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
if err != nil {
return nil, err
}
uploadID, err := driveUploadPut(ctx, credentialText, func(refreshCtx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(refreshCtx, "drive", "get_upload_info", step1Args)
}, request.FilePath, request.FileSize)
if err != nil {
return nil, err
}
commitArgs := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
"uploadId": uploadID,
}
if request.SpaceID != "" {
commitArgs["spaceId"] = request.SpaceID
}
if request.OverwriteFile != "" {
commitArgs["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
commitArgs["parentId"] = request.ParentID
}
commitText, err := callMCPToolReturnTextOnServer(ctx, "drive", "commit_upload", commitArgs)
if err != nil {
return nil, err
}
if strings.TrimSpace(commitText) == "" {
return nil, fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
}
var result map[string]any
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
return nil, fmt.Errorf("parse commit_upload response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
}
return result, nil
}
func newDriveCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/drive.json
// products.drive). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
ID: "drive",
Selection: contract.ProductSelectionDecl{
AgentSummary: "管理钉盘及文档空间中的文件、目录、上传下载、回收站与公开发布",
UseWhen: []string{
"浏览、搜索、上传、下载或整理钉盘和文档空间文件时",
},
AvoidWhen: []string{
"需要读取或编辑在线文档正文时使用 doc;需要管理知识库空间或成员时使用 wiki",
},
},
})
driveCmd := newGroupCommand(&cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
RunE: groupRunE,
})
driveListCmd := &cobra.Command{
Use: "list",
Short: "获取文件/文件夹列表(统一入口)",
Long: `列出文件和文件夹。根据参数自动路由到钉盘或文档空间。
路由规则:
默认(无 --workspace) → 列出钉盘「我的文件」
--space-id <纯数字> → 列出钉盘指定空间
--workspace <加密string/URL> → 列出文档空间/知识库文件(等同于原 list-docs)
--folder <nodeId> → 列出指定文件夹下的子节点`,
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一);
// 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。
filter, err := parseDriveListFilter(cmd)
if err != nil {
return err
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
if cmd.Flags().Changed("versions") {
if cmd.Flags().Changed("depth") && depth > 1 {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --depth 不能同时使用",
}
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --pattern 不能同时使用",
}
}
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPToolOnServer("drive", "list_file_versions", toolArgs)
}
if cmd.Flags().Changed("depth") {
if err := validateDriveListDepth(cmd, depth); err != nil {
return err
}
}
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
// filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。
if depth > 1 || latest > 0 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
if rootFolder != "" {
if err := validateDocFolderID(rootFolder); err != nil {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--pattern 仅适用于钉盘文件列表,不能与 --workspace 同时使用",
}
}
toolArgs := map[string]any{"workspaceId": workspaceID}
if folder := docFolderFlag(cmd, "node", "file-id"); folder != "" {
if err := validateDocFolderID(folder); err != nil {
return err
}
toolArgs["folderId"] = folder
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["pageSize"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" {
toolArgs["pageToken"] = v
}
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
}
// 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛);
// filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。
if depth > 1 || filter.active() {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
baseArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("order"); v != "" {
baseArgs["order"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
}
// 默认路由:钉盘文件列表
if latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
maxResults = v
}
}
if maxResults <= 0 {
maxResults = 20
}
if maxResults > 50 {
maxResults = 50
}
argsMap := map[string]any{"maxResults": float64(maxResults)}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
if parentID := flagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
argsMap["parentId"] = parentID
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
argsMap["nextToken"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
argsMap["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("order"); v != "" {
argsMap["order"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
argsMap["withThumbnail"] = true
}
// --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛);
// 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。
if pattern != "" {
return callDriveListPageWithPattern(argsMap, pattern)
}
return callMCPTool("list_files", argsMap)
},
}
DeclareLeafMetadata(driveListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_files",
CanonicalPath: "drive.list_files",
CLIPath: "drive list",
PrimaryCLIPath: "drive list",
},
Description: "获取文件/文件夹列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取文件/文件夹列表",
UseWhen: []string{
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
"要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)",
},
AvoidWhen: []string{
"只记得关键词、不知道所在目录时改用 dws drive search",
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
"要找最近打开/编辑过的文档改用 dws drive recent",
"带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)",
},
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --latest 5 --format json",
},
},
},
})
driveInfoCmd := &cobra.Command{
Use: "info",
Short: "获取文件元数据信息",
Long: `获取钉盘文件/文件夹的元数据信息。
如果目标文件属于钉钉文档(在线文档/表格/脑图等),会自动跟进调用
钉钉文档接口获取更准确的文档信息(如真实文档名称),并合并输出。`,
Example: ` dws drive info --node <dentryUuid> # 查询 fileId: dws drive list`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID := flagOrFallback(cmd, "node", "file-id")
if fileID == "" {
return fmt.Errorf("flag --node is required")
}
argsMap := map[string]any{"fileId": fileID}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
return driveInfoWithDocFallback(fileID, argsMap)
},
}
DeclareLeafMetadata(driveInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_file_info",
CanonicalPath: "drive.get_file_info",
CLIPath: "drive info",
PrimaryCLIPath: "drive info",
},
Description: "获取文件元数据信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_file_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取文件元数据信息",
UseWhen: []string{
"用户要查看钉盘文件/文件夹元信息(名称、类型、大小、路径、时间)时",
"准备读内容前需先判断 extension/是否在线文档,再路由到 doc read / sheet / download 时",
},
AvoidWhen: []string{
"要读在线文档正文改用 dws doc read(先本命令或 doc info 确认类型)",
"要下载普通文件改用 dws drive download",
"只要目录列表改用 dws drive list",
},
Examples: []string{"dws drive info --node <dentryUuid> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "fileId"},
},
},
})
driveDownloadCmd := &cobra.Command{
Use: "download",
Short: "下载钉盘文件到本地",
Long: `下载钉盘中的文件到本地(两步下载流程)。
流程:
1. 获取下载 URL 和签名请求头 (download_file)
2. HTTP GET 下载文件二进制内容到本地
--output 指定本地保存路径,可以是文件路径或目录。
如果指定目录,文件名从下载 URL 中自动推断。`,
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID := flagOrFallback(cmd, "node", "file-id")
if fileID == "" {
return fmt.Errorf("flag --node is required")
}
outputPath, _ := cmd.Flags().GetString("output")
if outputPath == "" {
return fmt.Errorf("flag --output is required")
}
argsMap := map[string]any{"fileId": fileID}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download",
"nodeId": fileID,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
deps.Out.PrintKeyValue("文件ID", fileID)
deps.Out.PrintKeyValue("输出", outputPath)
return nil
}
ctx := cmd.Context()
// Step 1: 获取下载 URL 和签名请求头
printJSONSafeInfo("[1/2] 获取下载链接...")
text, err := callMCPToolReturnText(ctx, "download_file", argsMap)
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
// 如果 output 是目录,优先从 MCP 返回的 fileName,fallback 到从 URL 推断
fi, statErr := os.Stat(outputPath)
if statErr == nil && fi.IsDir() {
filename := extractFileNameFromResponse(text)
if filename == "" {
filename = inferFilename(resourceURL)
}
outputPath = filepath.Join(outputPath, filename)
}
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = parseDownloadFileVersion(text)
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": dlOpts.version,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
}
DeclareLeafMetadata(driveDownloadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "download_file",
CanonicalPath: "drive.download_file",
CLIPath: "drive download",
PrimaryCLIPath: "drive download",
},
Description: "下载钉盘或文档空间文件到本地",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "download_file"},
},
Selection: contract.SelectionSpec{
AgentSummary: "下载钉盘或文档空间文件到本地",
UseWhen: []string{
"用户要把钉盘普通文件(PDF/图片/Office 等非在线文档)下载到本地路径时",
"已确认 contentType 非 ALIDOC,需要落盘本地查看时",
},
AvoidWhen: []string{
"在线文档(adoc)要导出为 Word/docx 改用 dws doc export,不要用 download 代替导出",
"只要临时下载链接语义且走文档附件块时用 dws doc media download",
"未指定 --output 不要调用(CLI 必填)",
},
Examples: []string{
"dws drive download --node <dentryUuid> --output ./report.pdf --format json",
"dws drive download --node <dentryUuid> --output ~/downloads/ --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "fileId"},
// CLI-local multipart transfer knobs (not MCP properties; see mapping exclusions).
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
driveDownloadVersionCmd := &cobra.Command{
Use: "download-version",
Short: "下载文件历史版本到本地",
Long: `下载钉盘文件的指定历史版本到本地(两步下载流程)。
仅适用于普通文件(如 pdf、docx、xlsx、png 等):
钉钉在线文档(adoc)请使用 dws doc version 系列命令
钉钉在线表格(axls)请使用 dws sheet version 系列命令
流程:
1. 获取历史版本下载 URL 和签名请求头 (download_file_version)
2. HTTP GET 下载文件二进制内容到本地
版本号从 dws drive list --node <dentryUuid> --versions 获取。`,
Example: ` dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf
dws drive download-version --node <dentryUuid> --version 3 --output ~/downloads/`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
versionNum, _ := cmd.Flags().GetInt("version")
if versionNum <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d(版本号从 drive list --versions 获取)", versionNum)
}
outputPath, _ := cmd.Flags().GetString("output")
if outputPath == "" {
return fmt.Errorf("flag --output is required")
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download_version",
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
deps.Out.PrintKeyValue("输出", outputPath)
return nil
}
ctx := cmd.Context()
printJSONSafeInfo("[1/2] 获取历史版本下载链接...")
dlArgsMap := map[string]any{
"nodeId": fileID,
"version": versionNum,
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap)
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
filename := extractFileNameFromResponse(text)
if filename == "" {
filename = inferFilename(resourceURL)
}
outputPath = filepath.Join(outputPath, filename)
}
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = versionNum
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
}
DeclareLeafMetadata(driveDownloadVersionCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "download_file_version",
CanonicalPath: "drive.download_file_version",
CLIPath: "drive download-version",
PrimaryCLIPath: "drive download-version",
},
Description: "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "下载钉盘普通文件的指定历史版本到本地(两步下载:取签名 URL 后 HTTP GET)",
UseWhen: []string{
"用户要下载文件的历史版本/旧版本",
"版本号已通过 drive list --versions 获取",
},
AvoidWhen: []string{
"下载最新版本用 drive download",
"在线文档(adoc)历史版本用 doc version 系列命令",
"在线表格(axls)历史版本用 sheet version 系列命令",
},
Examples: []string{"dws drive download-version --node <dentryUuid> --version 3 --output ./report_v3.pdf"},
},
Parameters: []contract.ParamDecl{
// CLI-local multipart transfer knobs (not interface properties; see mapping exclusions).
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
},
},
})
driveMkdirCmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
Example: ` dws drive mkdir --name "项目资料"
dws drive mkdir --name "子目录" --folder <dentryUuid>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "name"); err != nil {
return err
}
argsMap := map[string]any{"name": mustGetFlag(cmd, "name")}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
if parentID := flagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
argsMap["parentId"] = parentID
}
return callMCPTool("create_folder", argsMap)
},
}
DeclareLeafMetadata(driveMkdirCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "create_folder",
CanonicalPath: "drive.create_folder",
CLIPath: "drive mkdir",
PrimaryCLIPath: "drive mkdir",
},
Description: "创建文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "create_folder"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建文件夹",
UseWhen: []string{
"用户要在钉盘/「我的文件」下新建普通文件夹时",
"已知父目录 dentryUuid,要在其下建子目录时",
},
AvoidWhen: []string{
"要在知识库内建文件夹改用 dws wiki node create --type folder --workspace <id>",
"要创建在线文档(adoc)改用 dws doc create 或 wiki node create --type adoc",
},
Examples: []string{
"dws drive mkdir --name \"项目资料\" --format json",
"dws drive mkdir --name \"子目录\" --folder <dentryUuid> --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "parentId"},
},
},
})
driveUploadInfoCmd := &cobra.Command{
Use: "upload-info",
Short: "获取文件上传信息",
Example: ` dws drive upload-info --file-name "报告.pdf" --file-size 102400
dws drive upload-info --file-name "readme.txt" --file-size 1024 --folder <dentryUuid>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "file-name"); err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
if fileSize <= 0 {
return fmt.Errorf("flag --file-size is required and must be a positive integer")
}
argsMap := map[string]any{
"fileName": mustGetFlag(cmd, "file-name"),
"fileSize": float64(fileSize),
}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
if v, _ := cmd.Flags().GetString("mime-type"); v != "" {
argsMap["mimeType"] = v
}
if v := flagOrFallback(cmd, "folder", "parent-id"); v != "" {
if err := validateDriveParentID(v); err != nil {
return err
}
argsMap["parentId"] = v
}
return callMCPTool("get_upload_info", argsMap)
},
}
DeclareLeafMetadata(driveUploadInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_upload_info",
CanonicalPath: "drive.get_upload_info",
CLIPath: "drive upload-info",
PrimaryCLIPath: "drive upload-info",
},
Description: "获取文件上传信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_upload_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取文件上传信息",
UseWhen: []string{"仅当无法使用 drive upload 一条命令、需要自定义流式上传时,获取 OSS 预签名上传凭证"},
AvoidWhen: []string{
"普通上传请直接用 dws drive upload,不要手动走三步",
"拿到凭证后须 HTTP PUT 再 dws drive commit;本命令本身不完成入库",
},
Examples: []string{"dws drive upload-info --file-name \"report.pdf\" --file-size 102400 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "parentId"},
},
},
})
driveCommitCmd := &cobra.Command{
Use: "commit",
Short: "提交文件上传",
Example: ` dws drive commit --file-name "报告.pdf" --file-size 102400 --upload-id <uploadId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "file-name", "upload-id"); err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
if fileSize <= 0 {
return fmt.Errorf("flag --file-size is required and must be a positive integer")
}
argsMap := map[string]any{
"fileName": mustGetFlag(cmd, "file-name"),
"fileSize": float64(fileSize),
"uploadId": mustGetFlag(cmd, "upload-id"),
}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
argsMap["spaceId"] = v
}
if v := flagOrFallback(cmd, "folder", "parent-id"); v != "" {
if err := validateDriveParentID(v); err != nil {
return err
}
argsMap["parentId"] = v
}
return callMCPTool("commit_upload", argsMap)
},
}
DeclareLeafMetadata(driveCommitCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "commit_upload",
CanonicalPath: "drive.commit_upload",
CLIPath: "drive commit",
PrimaryCLIPath: "drive commit",
},
Description: "提交文件上传",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "commit_upload"},
},
Selection: contract.SelectionSpec{
AgentSummary: "提交文件上传",
UseWhen: []string{"手动三步上传的最后一步:已 PUT 到 OSS,用 upload-info 返回的 uploadId 提交入库时"},
AvoidWhen: []string{
"普通上传请用 dws drive upload",
"尚未 PUT 成功或 uploadId 过期时不要 commit;需重新 upload-info",
},
Examples: []string{"dws drive commit --file-name \"report.pdf\" --file-size 102400 --upload-id <UPLOAD_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "parentId"},
},
},
})
driveListCmd.Flags().Int("limit", 20, "每页返回数量,默认 20,最大 50")
driveListCmd.Flags().Int("max", 0, "--limit 的别名(向后兼容)")
_ = driveListCmd.Flags().MarkHidden("max")
driveListCmd.Flags().String("space-id", "", "钉盘空间 ID (纯数字),不传则使用「我的文件」(可选)")
driveListCmd.Flags().String("workspace", "", "文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选)")
driveListCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
driveListCmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
driveListCmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选,仅钉盘)")
driveListCmd.Flags().String("order", "", "排序方向: asc|desc,默认 desc (可选,仅钉盘)")
driveListCmd.Flags().Bool("thumbnail", false, "是否返回缩略图信息 (可选,仅钉盘)")
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
driveMkdirCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)")
driveUploadInfoCmd.Flags().String("file-name", "", "文件名,须包含扩展名,如 报告.pdf (必填)")
driveUploadInfoCmd.Flags().Int64("file-size", 0, "文件大小(字节)(必填)")
_ = driveUploadInfoCmd.MarkFlagRequired("file-size")
driveUploadInfoCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
driveUploadInfoCmd.Flags().String("mime-type", "", "文件 MIME 类型,如 application/pdf,不传则自动推断 (可选)")
driveUploadInfoCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
driveCommitCmd.Flags().String("file-name", "", "文件名(含扩展名),须与 get_upload_info 时一致 (必填)")
driveCommitCmd.Flags().Int64("file-size", 0, "文件大小(字节),须与 get_upload_info 时一致 (必填)")
_ = driveCommitCmd.MarkFlagRequired("file-size")
driveCommitCmd.Flags().String("upload-id", "", "上传 ID,来自 get_upload_info 返回的 uploadId (必填)")
driveCommitCmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」 (可选)")
driveCommitCmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则提交到根目录 (可选)")
driveUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件到钉盘或文档空间",
Long: `将本地文件上传(三步自动完成)。
路由规则:
默认(无 --workspace) → 上传到钉盘(我的文件或指定空间)
--workspace <id> → 上传到文档空间/知识库
流程:
1. 获取 OSS 上传凭证
2. HTTP PUT 上传文件二进制到 OSS
3. 提交文件入库
上传位置: --folder 指定父目录,不传则上传到空间根目录。`,
Example: ` dws drive upload --file ./report.pdf
dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
dws drive upload --file ./data.xlsx --folder <dentryUuid>
dws drive upload --file ./doc.pdf --workspace <workspaceId>
dws drive upload --file ./data.xlsx --workspace <workspaceId> --convert`,
RunE: runDriveUpload,
}
DeclareLeafMetadata(driveUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "upload",
CanonicalPath: "drive.upload",
CLIPath: "drive upload",
PrimaryCLIPath: "drive upload",
},
Description: "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "上传本地文件到钉盘或文档空间,或按节点 ID 确认覆盖已有文件",
UseWhen: []string{
"用户要把本地文件上传到钉盘/我的文件(首选一条命令自动完成凭证+PUT+提交)时",
"上传到知识库/文档空间时加 --workspace;需要转在线文档时加 --convert",
"用户明确要求用本地文件替换已有钉盘/文档空间文件时传 --node;该模式会覆盖远端内容并要求确认",
},
AvoidWhen: []string{
"常规场景不要拆成 upload-info + 手动 PUT + commit;仅自定义流式上传才用三步",
"要把文件作为文档正文附件插入改用 dws doc media insert",
"用户明确说文档空间且走 doc 兼容入口时可用 dws doc upload,默认仍推荐本命令",
"用户没有明确同意替换目标文件时不要使用 --node;新建上传应使用 --folder 或目标根目录",
},
Examples: []string{
"dws drive upload --file ./report.pdf --format json",
"dws drive upload --file ./README.md --node <dentryUuid> --format json",
},
},
// Composite multi-step leaf (get_upload_info → PUT → commit_upload):
// no single RPCName / interface_ref. Keep --node→nodeId on this leaf
// only; do not hang it on upload-info or commit.
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(false)},
},
},
})
driveUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
driveUploadCmd.Flags().String("file-name", "", "文件显示名称 (默认使用文件名)")
driveUploadCmd.Flags().String("space-id", "", "目标钉盘空间 ID,不传则使用「我的文件」 (可选)")
driveUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
driveUploadCmd.Flags().String("folder", "", "父节点 ID,不传则上传到空间根目录 (可选,与 --node 互斥)")
driveUploadCmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传 (可选)")
driveUploadCmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档 (仅文档空间上传时生效)")
driveUploadCmd.Flags().String("node", "", "覆盖目标文件 ID,传入即覆盖已有文件(与 --folder 互斥)(可选)")
driveListSpacesCmd := &cobra.Command{
Use: "list-spaces",
Short: "获取钉盘空间列表 (deprecated → dws wiki space list --type orgSpace/mySpace)",
Long: `⚠️ 此命令已迁移到 wiki space list,请使用:
dws wiki space list --type orgSpace # 企业空间
dws wiki space list --type mySpace # 我的文件
列出当前用户可访问的钉盘空间,返回 spaceId、spaceName、rootFolderId 等信息。`,
Example: ` dws wiki space list --type orgSpace # 推荐
dws wiki space list --type mySpace # 推荐
dws drive list-spaces # deprecated`,
RunE: func(cmd *cobra.Command, args []string) error {
deps.Out.PrintWarning("⚠️ 'dws drive list-spaces' is deprecated, use 'dws wiki space list --type orgSpace' or 'dws wiki space list --type mySpace' instead.")
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
maxResults = v
}
}
argsMap := map[string]any{}
if maxResults > 0 {
argsMap["maxResults"] = float64(maxResults)
}
if v, _ := cmd.Flags().GetString("space-type"); v != "" {
argsMap["spaceType"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
argsMap["nextToken"] = v
}
return callMCPTool("list_spaces", argsMap)
},
}
DeclareLeafMetadata(driveListSpacesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_spaces",
CanonicalPath: "drive.list_spaces",
CLIPath: "drive list-spaces",
PrimaryCLIPath: "drive list-spaces",
},
Description: "兼容查询钉盘空间列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "list_spaces"},
},
Selection: contract.SelectionSpec{
AgentSummary: "兼容查询钉盘空间列表",
UseWhen: []string{"兼容入口:枚举钉盘企业空间或「我的文件」空间以拿 spaceId/rootFolderId 时"},
AvoidWhen: []string{
"推荐改用 dws wiki space list --type orgSpace 或 --type mySpace(本命令已 deprecated)",
"要列知识库列表改用 dws wiki space list(默认 orgWikiSpace)",
},
Examples: []string{
"dws drive list-spaces --space-type orgSpace --limit 20 --format json",
"dws drive list-spaces --space-type mySpace --format json",
},
},
// MCP pins maxResults as number while the Cobra flag is integer. Publishing
// interface_type=number is a merge-base contract change; declare integer so
// resolution equals cobra_flag_type and omits a separate interface_type field.
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "maxResults", InterfaceType: "integer"},
{Name: "cursor", Property: "nextToken"},
},
},
})
driveListSpacesCmd.Flags().Int("limit", 20, "每页返回数量 (默认 20,最大 50),仅 spaceType 为 orgSpace 时有效")
driveListSpacesCmd.Flags().Int("max", 0, "--limit 的别名(向后兼容)")
_ = driveListSpacesCmd.Flags().MarkHidden("max")
driveListSpacesCmd.Flags().String("space-type", "", "空间类型: orgSpace=企业空间(默认), mySpace=我的文件 (可选)")
driveListSpacesCmd.Flags().String("cursor", "", "分页游标,仅企业空间支持分页 (可选)")
driveSearchCmd := &cobra.Command{
Use: "search",
Short: "搜索文件(聚合钉盘+文档空间)",
Long: `全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果。
搜索范围 (--target):
all (默认)同时搜钉盘文件与文档空间,聚合返回
file 只搜钉盘文件/文件夹,支持 --file-types / --extensions
space 只搜钉盘团队空间
如果需要在某个知识库内搜索,请使用 dws wiki node search --workspace <workspaceId>。
结果中 source 字段区分来源:drive / doc。
提示:结果按相关性排序,首页未命中时优先调整关键词 / 过滤条件,而非反复翻页。`,
Example: ` dws drive search --query "季度汇报"
dws drive search --query "合同" --target file --extensions pdf,docx
dws drive search --query "项目" --target space
dws drive search --query "报告" --limit 30 --cursor <pageToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := flagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return fmt.Errorf("flag --query is required")
}
target, _ := cmd.Flags().GetString("target")
// 构建钉盘搜索参数
argsMap := map[string]any{"keyword": keyword}
if target != "" && target != "all" {
argsMap["searchTarget"] = target
}
if v, _ := cmd.Flags().GetStringSlice("file-types"); len(v) > 0 {
argsMap["fileTypes"] = v
}
if v, _ := cmd.Flags().GetStringSlice("extensions"); len(v) > 0 {
argsMap["extensions"] = v
}
if v, _ := cmd.Flags().GetStringSlice("creator-uids"); len(v) > 0 {
argsMap["creatorUserIds"] = v
}
if cmd.Flags().Changed("created-from") {
if v, _ := cmd.Flags().GetInt64("created-from"); v > 0 {
argsMap["createdTimeFrom"] = v
}
}
if cmd.Flags().Changed("created-to") {
if v, _ := cmd.Flags().GetInt64("created-to"); v > 0 {
argsMap["createdTimeTo"] = v
}
}
if cmd.Flags().Changed("modified-from") {
if v, _ := cmd.Flags().GetInt64("modified-from"); v > 0 {
argsMap["modifiedTimeFrom"] = v
}
}
if cmd.Flags().Changed("modified-to") {
if v, _ := cmd.Flags().GetInt64("modified-to"); v > 0 {
argsMap["modifiedTimeTo"] = v
}
}
pageSize, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("page-size"); v > 0 {
pageSize = v
}
}
if pageSize > 0 {
argsMap["pageSize"] = float64(pageSize)
}
if v := flagOrFallback(cmd, "cursor", "page-token"); v != "" {
argsMap["pageToken"] = v
}
// --target file/space: 仅搜钉盘
if target == "file" || target == "space" {
return callMCPTool("search_files", argsMap)
}
// --target all (默认): 聚合搜索钉盘+文档空间
ctx := context.Background()
// 1) 钉盘搜索
driveText, driveErr := callMCPToolReturnText(ctx, "search_files", argsMap)
// 2) 文档空间搜索
docArgs := map[string]any{"keyword": keyword}
if pageSize > 0 {
docArgs["pageSize"] = pageSize
}
docText, docErr := callMCPToolReturnTextOnServer(ctx, "doc", "search_documents", docArgs)
// 合并输出
// 双路全失败时返回 error;仅一路失败时静默忽略,只输出成功方结果
if driveErr != nil && docErr != nil {
return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
}
result := map[string]any{}
if driveErr == nil && driveText != "" {
var driveResult any
if json.Unmarshal([]byte(driveText), &driveResult) == nil {
result["drive_results"] = driveResult
} else {
result["drive_results"] = driveText
}
}
if docErr == nil && docText != "" {
var docResult any
if json.Unmarshal([]byte(docText), &docResult) == nil {
result["doc_results"] = docResult
} else {
result["doc_results"] = docText
}
}
merged, _ := json.MarshalIndent(result, "", " ")
deps.Out.PrintRaw(string(merged))
return nil
},
}
DeclareLeafMetadata(driveSearchCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "search_files",
CanonicalPath: "drive.search_files",
CLIPath: "drive search",
PrimaryCLIPath: "drive search",
},
Description: "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "search_files"},
},
Selection: contract.SelectionSpec{
AgentSummary: "全局搜索文件,默认同时搜索钉盘和文档空间,合并返回结果",
UseWhen: []string{
"用户要在钉盘/我的文件里按关键词找文件、文件夹或团队空间,且不知道具体路径时",
"需要按扩展名/文件类型/创建者/时间缩小范围的全局搜索(默认 target=all 聚合钉盘+文档空间)",
"明确搜团队空间名以拿 spaceId/rootFolderId 时用 --target space",
},
AvoidWhen: []string{
"已明确知识库 workspaceId、只在该库内搜时改用 dws wiki node search --workspace <id>",
"已知目录、只需浏览子项时改用 dws drive list,不要用搜索代替目录遍历",
"首页未命中时优先改关键词/过滤条件,而不是反复翻页",
},
Examples: []string{
"dws drive search --query \"季度汇报\" --format json",
"dws drive search --query \"合同\" --target file --extensions pdf,docx --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "created-from", Property: "createdTimeFrom"},
{Name: "created-to", Property: "createdTimeTo"},
{Name: "creator-uids", Property: "creatorUserIds"},
{Name: "cursor", Property: "pageToken"},
{Name: "limit", Property: "pageSize"},
{Name: "modified-from", Property: "modifiedTimeFrom"},
{Name: "modified-to", Property: "modifiedTimeTo"},
{Name: "query", Property: "keyword"},
{Name: "target", Property: "searchTarget"},
},
},
})
driveSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
driveSearchCmd.Flags().String("keyword", "", "--query 的别名(向后兼容)")
_ = driveSearchCmd.Flags().MarkHidden("keyword")
driveSearchCmd.Flags().String("target", "", "搜索范围: all(默认,聚合钉盘+文档空间) | file(仅钉盘文件) | space(仅钉盘空间) (可选)")
driveSearchCmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤,逗号分隔: alidoc,document,image,video,audio,archive (仅 target=file/all 生效)")
driveSearchCmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,adoc)")
driveSearchCmd.Flags().StringSlice("creator-uids", nil, "按创建者用户 ID 过滤,逗号分隔")
driveSearchCmd.Flags().Int64("created-from", 0, "创建时间起始 (毫秒时间戳,含)")
driveSearchCmd.Flags().Int64("created-to", 0, "创建时间截止 (毫秒时间戳,含)")
driveSearchCmd.Flags().Int64("modified-from", 0, "修改时间起始 (毫秒时间戳,含)")
driveSearchCmd.Flags().Int64("modified-to", 0, "修改时间截止 (毫秒时间戳,含)")
driveSearchCmd.Flags().Int("limit", 0, "每页返回数量(默认 10,最大 30)")
driveSearchCmd.Flags().Int("page-size", 0, "--limit 的别名(向后兼容)")
_ = driveSearchCmd.Flags().MarkHidden("page-size")
driveSearchCmd.Flags().String("cursor", "", "分页游标,从上次返回的 nextCursor 获取 (可选)")
driveSearchCmd.Flags().String("page-token", "", "--cursor 的别名(向后兼容)")
_ = driveSearchCmd.Flags().MarkHidden("page-token")
driveDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除文件/文件夹到回收站",
Long: `将钉盘中的文件或文件夹移入回收站。
注意: 这是一个危险操作,文件将被移入回收站。执行前需要确认,或传入 --yes 跳过确认。
--node 对应 drive list 返回的 fileId 字段(即 dentryUuid)。
权限要求: 对文档有"管理"权限。`,
Example: ` dws drive delete --node <dentryUuid> --yes # 查询 fileId: dws drive list
dws drive delete --node <dentryUuid> # 交互式确认后删除`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID := flagOrFallback(cmd, "node", "file-id")
if fileID == "" {
return fmt.Errorf("flag --node is required")
}
// 同 dws doc delete:delete_document 工具仅注册在 doc MCP server 上,
// 钉盘节点(fileId)与文档节点共用同一套 dentryUuid 体系,因此显式
// 路由到 doc server 才能找到该工具。若使用 callMCPTool 让 resolveProductID
// 路由,会被路由到 drive server,服务端会返回 PARAM_ERROR - 未找到指定工具。
return callMCPToolOnServer("doc", "delete_document", map[string]any{
"nodeId": fileID,
})
},
}
DeclareLeafMetadata(driveDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "delete_document",
CanonicalPath: "drive.delete_document",
CLIPath: "drive delete",
PrimaryCLIPath: "drive delete",
},
Description: "将钉盘中的文件或文件夹移入回收站",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "delete_document"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将钉盘中的文件或文件夹移入回收站",
UseWhen: []string{"用户明确要求把钉盘/文档空间中的文件或文件夹移入回收站,且已确认目标节点时"},
AvoidWhen: []string{
"用户未确认删除目标或只是想移走位置时不要删;搬迁用 dws drive move",
"要删整个知识库改用 dws wiki space delete",
"要永久删评论改用 dws doc comment delete",
},
Examples: []string{"dws drive delete --node <dentryUuid> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
driveDeleteCmd.Flags().String("node", "", "文件/文件夹 ID (dentryUuid),即 drive list 返回的 fileId (必填)")
// ── 文档空间代理命令(从 doc 迁入,显式路由到 doc MCP server)──
driveCopyCmd := &cobra.Command{
Use: "copy",
Short: "复制文件/文档到指定位置",
Long: `将文档空间中的文档或文件复制到指定文件夹或知识库。
--folder 指定目标文件夹 nodeId,--workspace 指定目标知识库 ID。
不传 --folder 时复制到 --workspace 根目录;都不传则默认到"我的文档"。
权限要求: 对源文档有"阅读"权限,且对目标文件夹有"编辑"权限。`,
Example: ` dws drive copy --node DOC_ID --folder TARGET_FOLDER_ID
dws drive copy --node DOC_ID --workspace TARGET_WS_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v := docFolderFlag(cmd); v != "" {
if err := validateDocFolderID(v); err != nil {
return err
}
toolArgs["targetFolderId"] = v
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "copy_document", toolArgs)
},
}
DeclareLeafMetadata(driveCopyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "copy_document",
CanonicalPath: "drive.copy_document",
CLIPath: "drive copy",
PrimaryCLIPath: "drive copy",
},
Description: "将文件或文档复制到目标文件夹或知识库(保留原位置;默认「我的文档」)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "copy_document"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将文件或文档复制到目标文件夹或知识库(保留原位置;默认「我的文档」)",
UseWhen: []string{
"用户要复制/拷贝一份文件或文档到新位置,且原位置仍需保留副本时(copy≠move)",
"目标是指定文件夹:传 --folder <目标文件夹 dentryUuid/fileId>",
"目标是知识库根目录:只传 --workspace <workspaceId 或知识库 URL>,不传 --folder",
"用户未指定目标时:默认落到当前组织「我的文档」;若需钉盘「我的文件」根目录,先 wiki space list --type mySpace 取 rootFolderId 再 --folder",
"跨钉盘 space 复制到子文件夹:先 list 取目标文件夹 fileId,再 --folder 传入",
},
AvoidWhen: []string{
"用户意图是搬走/迁移且原位置不再保留时改用 dws drive move(move 需更高源权限)",
"只要快捷方式入口、不要独立副本时改用 dws drive shortcut",
"已在明确知识库上下文内复制节点且需带 --workspace/--node 的 wiki 入口时可用 dws wiki node copy;跨产品默认用本命令",
"目标文件夹或知识库尚未确认时不要复制",
},
Examples: []string{
"dws drive copy --node <源dentryUuid> --folder <目标文件夹fileId> --format json",
"dws drive copy --node <源dentryUuid> --workspace <TARGET_WS_ID> --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "targetFolderId"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
driveCopyCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
driveCopyCmd.Flags().String("folder", "", "目标文件夹 nodeId")
driveCopyCmd.Flags().String("workspace", "", "目标知识库 ID")
driveMoveCmd := &cobra.Command{
Use: "move",
Short: "移动文件/文档到指定位置",
Long: `将文档空间中的文档或文件移动到指定文件夹或知识库。移动后原位置不再存在。
--folder 指定目标文件夹 nodeId,--workspace 指定目标知识库 ID。
权限要求: 对源文档有"管理"权限,且对目标文件夹有"编辑"权限。`,
Example: ` dws drive move --node DOC_ID --folder TARGET_FOLDER_ID
dws drive move --node DOC_ID --workspace TARGET_WS_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v := docFolderFlag(cmd); v != "" {
if err := validateDocFolderID(v); err != nil {
return err
}
toolArgs["targetFolderId"] = v
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "move_document", toolArgs)
},
}
DeclareLeafMetadata(driveMoveCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "move_document",
CanonicalPath: "drive.move_document",
CLIPath: "drive move",
PrimaryCLIPath: "drive move",
},
Description: "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "move_document"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将文件或文档移动到目标文件夹或知识库(原位置不再保留)",
UseWhen: []string{
"用户要移动/搬走文件或文档到新位置,且原位置不再保留时(move≠copy)",
"目标文件夹用 --folder;目标知识库根用 --workspace;都不传则默认「我的文档」",
"移动到其他钉盘 space 根目录时传目标 space 的 rootFolderId 到 --folder(通常不传 --workspace)",
},
AvoidWhen: []string{
"需要保留原位置副本时改用 dws drive copy",
"目标未确认或用户只是想复制时不要 move",
"知识库内节点移动且走 wiki 入口时可用 dws wiki node move",
},
Examples: []string{
"dws drive move --node <源dentryUuid> --folder <目标文件夹fileId> --format json",
"dws drive move --node <源dentryUuid> --workspace <TARGET_WS_ID> --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "targetFolderId"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
driveMoveCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
driveMoveCmd.Flags().String("folder", "", "目标文件夹 nodeId")
driveMoveCmd.Flags().String("workspace", "", "目标知识库 ID")
driveRenameCmd := &cobra.Command{
Use: "rename",
Short: "重命名文件/文档",
Long: `修改文档空间中文档、文件或文件夹的名称。
实际执行前会读取节点类型和当前扩展名:文件的新名称仅在末尾扩展名与当前扩展名一致时去掉一层,
避免 report.txt.txt;文件夹和扩展名不匹配的名称保持不变。dry-run 不读取远端元数据,因此保留输入名称。
权限要求: 对文档有"编辑"权限。`,
Example: ` dws drive rename --node DOC_ID --name "新名称"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
return err
}
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
newName := flagOrFallback(cmd, "name", "title")
if !commandDryRun(cmd) {
newName, err = resolveDriveRenameName(cmd.Context(), nodeID, newName)
if err != nil {
return err
}
}
return callMCPToolOnServer("doc", "rename_document", map[string]any{
"nodeId": nodeID,
"newName": newName,
})
},
}
DeclareLeafMetadata(driveRenameCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "rename_document",
CanonicalPath: "drive.rename_document",
CLIPath: "drive rename",
PrimaryCLIPath: "drive rename",
},
Description: "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "rename_document"},
},
Selection: contract.SelectionSpec{
AgentSummary: "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
UseWhen: []string{"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"},
AvoidWhen: []string{
"要改正文里的标题/章节 H1 改用 dws doc block update,不要用 rename",
"只要复制或移动改用 copy/move",
"dry-run 不读取节点元数据,输出名称尚未做基于当前扩展名的规范化",
},
Examples: []string{"dws drive rename --node <ID> --name \"新名称\" --format json"},
},
// Shared RPC rename_document with doc rename; keep this description
// on the drive leaf only (doc rename does not strip extensions).
Parameters: []contract.ParamDecl{
{Name: "name", Property: "newName", Description: "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名"},
{Name: "node", Property: "nodeId"},
},
},
})
driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
driveRenameCmd.Flags().String("name", "", "新名称 (必填;实际执行时仅去掉与节点当前扩展名完全匹配的一层后缀)")
driveStatsCmd := &cobra.Command{
Use: "stats",
Short: "获取节点统计信息",
Long: `获取指定节点的统计数据,包括阅读人数、阅读次数、编辑次数、评论数、点赞数、预览次数和下载次数等。
不同文件类型返回的统计维度可能不同。--node 支持节点 ID 或文档 URL。`,
Example: ` dws drive stats --node <dentryUuid>
dws drive stats --node https://alidocs.dingtalk.com/i/nodes/<dentryUuid>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_node_stats", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(driveStatsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_node_stats",
CanonicalPath: "drive.get_node_stats",
CLIPath: "drive stats",
PrimaryCLIPath: "drive stats",
},
Description: "读取指定钉盘或文档空间节点的阅读、编辑、评论、点赞、预览与下载等统计。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取指定钉盘或文档空间节点的阅读、编辑、评论、点赞、预览与下载等统计。",
UseWhen: []string{"用户要查看节点阅读/编辑/评论/点赞/预览/下载等统计维度时"},
AvoidWhen: []string{
"要改文件内容或权限不要用本命令;本命令只读",
"只要元信息(名称/类型)改用 dws drive info 或 dws doc info",
},
Examples: []string{"dws drive stats --node <NODE_ID_OR_URL> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
driveStatsCmd.Flags().String("node", "", "节点 ID 或文档 URL (必填)")
driveShortcutCmd := &cobra.Command{
Use: "shortcut",
Short: "为节点创建快捷方式",
Long: `为指定源节点创建快捷方式,并放置到目标文件夹或知识库。
通过 --node 指定源节点。--folder 和 --workspace 均为可选;都不传时由服务端选择默认位置。
若同时指定,--folder 是目标文件夹,--workspace 用于指定其所属知识库。`,
Example: ` dws drive shortcut --node <dentryUuid>
dws drive shortcut --node <dentryUuid> --folder <targetFolderId>
dws drive shortcut --node <dentryUuid> --workspace <workspaceId>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if v := docFolderFlag(cmd); v != "" {
if err := validateDocFolderID(v); err != nil {
return err
}
toolArgs["targetFolderId"] = v
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("drive", "create_shortcut", toolArgs)
},
}
DeclareLeafMetadata(driveShortcutCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "create_shortcut",
CanonicalPath: "drive.create_shortcut",
CLIPath: "drive shortcut",
PrimaryCLIPath: "drive shortcut",
},
Description: "为钉盘或文档空间中的现有节点创建快捷方式。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "为钉盘或文档空间中的现有节点创建快捷方式。",
UseWhen: []string{"用户要给已有节点建快捷方式/软链接到目标文件夹或知识库时"},
AvoidWhen: []string{
"要复制一份独立副本改用 dws drive copy(copy≠shortcut)",
"要移动原文件改用 dws drive move",
},
Examples: []string{
"dws drive shortcut --node <SOURCE_NODE> --format json",
"dws drive shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER> --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "targetFolderId"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
driveShortcutCmd.Flags().String("node", "", "源节点 ID 或文档 URL (必填)")
driveShortcutCmd.Flags().String("folder", "", "目标文件夹 nodeId (可选)")
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
// ── drive permission (文档节点权限管理) ──
drivePermissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档节点权限管理",
Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。
注意: 仅适用于文档空间节点,不适用于钉盘文件。`,
RunE: groupRunE,
})
drivePermAddCmd := &cobra.Command{
Use: "add",
Short: "添加协作者",
Args: cobra.NoArgs,
Long: `为文档空间节点添加协作成员并授予指定角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "add_permission", toolArgs)
},
}
DeclareLeafMetadata(drivePermAddCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "add_permission",
CanonicalPath: "drive.add_permission",
CLIPath: "drive permission add",
PrimaryCLIPath: "drive permission add",
},
Description: "为文档空间节点添加协作成员并授予指定角色",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "add_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为文档空间节点添加协作成员并授予指定角色",
UseWhen: []string{
"给单篇文档/文件夹/文件做节点级授权(USER + 角色 MANAGER/EDITOR/DOWNLOADER/READER)时",
"用户说把某篇文档分享给某人看/可编辑时(含「我的文档」下的节点)",
},
AvoidWhen: []string{
"给整个知识库加成员改用 dws wiki member add(「我的文档」不支持容器成员)",
"只查已有权限改用 dws drive permission list",
"改角色/移除分别用 permission update / remove",
},
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermAddCmd.Flags().String("user", "", "")
_ = drivePermAddCmd.Flags().MarkHidden("user")
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
drivePermUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新协作者权限",
Args: cobra.NoArgs,
Long: `更新文档空间节点已有协作者的权限角色。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, false)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
if cmd.Flags().Changed("notify") {
notify, _ := cmd.Flags().GetBool("notify")
toolArgs["notify"] = notify
}
} else {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "update_permission", toolArgs)
},
}
DeclareLeafMetadata(drivePermUpdateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "permission_update",
CanonicalPath: "drive.permission_update",
CLIPath: "drive permission update",
PrimaryCLIPath: "drive permission update",
},
Description: "更新文档空间节点已有协作者的权限角色",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "update_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "更新文档空间节点已有协作者的权限角色",
UseWhen: []string{"调整已有节点成员角色(如 READER→EDITOR)时"},
AvoidWhen: []string{
"成员尚无权限时改用 permission add",
"要移除访问改用 permission remove",
},
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "notify", Property: "notify"},
{Name: "role", Property: "roleId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermUpdateCmd.Flags().String("user", "", "")
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
drivePermListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询协作者列表",
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
出参包含 totalCount、hasMore 和 nextToken。
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
Example: ` dws drive permission list --node DOC_ID
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
return err
} else if ok {
toolArgs["pageSize"] = size
}
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
toolArgs["filterRoleIds"] = parseRoleList(v)
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "list_permission", toolArgs)
},
}
DeclareLeafMetadata(drivePermListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_permission",
CanonicalPath: "drive.list_permission",
CLIPath: "drive permission list",
PrimaryCLIPath: "drive permission list",
},
Description: "查询文档空间节点的协作者列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "list_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档空间节点的协作者列表",
UseWhen: []string{"查看某文档/文件夹节点当前有哪些成员及角色时"},
AvoidWhen: []string{
"要新增/修改/移除权限分别用 permission add/update/remove",
"查知识库容器成员改用 dws wiki member list",
},
Examples: []string{"dws drive permission list --node <ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "filter-role", Property: "filterRoleIds"},
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
{Name: "limit"},
{Name: "next-token", Property: "nextToken"},
{Name: "node", Property: "nodeId"},
{Name: "workspace", Property: "workspaceId"},
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
},
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
drivePermGetSettingCmd := &cobra.Command{
Use: "get-setting",
Short: "查询节点权限设置",
Long: `查询文档空间节点的权限设置,返回三部分配置:
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
- shareScope: 分享范围(可见范围、链接分享设置)
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
查询协作者列表请改用 permission list。`,
Example: ` dws drive permission get-setting --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_permission_setting",
CanonicalPath: "drive.get_permission_setting",
CLIPath: "drive permission get-setting",
PrimaryCLIPath: "drive permission get-setting",
},
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"节点权限设置(权限模式/分享范围/权限策略)",
"properties":{
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
"shareScope":{
"type":"object",
"description":"分享范围设置",
"properties":{
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
"linkShare":{
"type":"object",
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
"properties":{
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
},
"additionalProperties":true
}
},
"additionalProperties":true
},
"policies":{
"type":"array",
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
"items":{
"type":"object",
"description":"权限策略项",
"properties":{
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
"disabledValues":{
"type":"array",
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
"items":{
"type":"object",
"properties":{
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
},
"required":["value"],
"additionalProperties":true
}
},
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
},
"required":["code","name","description","disabledValues"],
"additionalProperties":true
}
}
},
"required":["docUrl","nodeId","shareScope","policies"],
"additionalProperties":true
}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
AvoidWhen: []string{
"查协作者清单用 permission list",
"查可申请角色与审批人用 permission apply-info",
},
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除协作者权限",
Long: `从文档空间节点移除协作成员的权限。
两种传参方式(互斥):
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
成员类型说明:
USER 用户,id 为用户 userId,需携带 corpId
DEPT 部门,id 为部门 ID,需携带 corpId
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
Example: ` dws drive permission remove --node DOC_ID --users uid1
dws drive permission remove --node DOC_ID --users uid1,uid2
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateMembersExclusivity(cmd); err != nil {
return err
}
toolArgs := map[string]any{"nodeId": nodeID}
members, mErr := collectMembers(cmd, true)
if mErr != nil {
return mErr
}
if len(members) > 0 {
toolArgs["members"] = members
} else {
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs["userIds"] = userIds
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "remove_permission", toolArgs)
},
}
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "permission_remove",
CanonicalPath: "drive.permission_remove",
CLIPath: "drive permission remove",
PrimaryCLIPath: "drive permission remove",
},
Description: "从文档空间节点移除协作成员的权限",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "remove_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "从文档空间节点移除协作成员的权限",
UseWhen: []string{"从节点移除指定用户的直接授权时"},
AvoidWhen: []string{
"要改角色用 permission update;要加权限用 add",
"移除知识库容器成员用 dws wiki member remove",
},
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "members", Property: "members"},
{Name: "node", Property: "nodeId"},
{Name: "users", Property: "userIds"},
{Name: "workspace", Property: "workspaceId"},
},
},
})
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
drivePermRemoveCmd.Flags().String("user", "", "")
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
c.Flags().String("doc-id", "", "")
c.Flags().String("file-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
drivePermTransferOwnerCmd := &cobra.Command{
Use: "transfer-owner",
Short: "[危险] 转交所有者",
Long: `转交文档或知识库的所有者给指定用户。此操作不可逆,执行前需要确认。
--node 和 --workspace 二选一。转交后原所有者保留角色由 --reserve-role 指定。
使用 --yes 跳过确认时,--reserve-role 和 --recursive 必须显式指定。`,
Example: ` dws drive permission transfer-owner --node DOC_ID --new-owner uid123 --reserve-role EDITOR --recursive=false --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, _ := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if nodeID == "" && workspaceID == "" {
return fmt.Errorf("--node or --workspace is required")
}
// 不可逆高危操作:帮助文档承诺二选一,两者同传直接失败,绝不静默择一。
if nodeID != "" && workspaceID != "" {
return fmt.Errorf("--node and --workspace are mutually exclusive; specify exactly one")
}
if err := validateRequiredFlags(cmd, "new-owner"); err != nil {
return err
}
newOwnerID := mustGetFlag(cmd, "new-owner")
yesMode, _ := cmd.Flags().GetBool("yes")
if yesMode {
if !cmd.Flags().Changed("reserve-role") {
return fmt.Errorf("--reserve-role is required when using --yes")
}
if !cmd.Flags().Changed("recursive") {
return fmt.Errorf("--recursive is required when using --yes")
}
}
if commandDryRun(cmd) {
if deps.Caller.Format() == "json" {
payload := map[string]any{
"dry_run": true,
"executed": false,
"operation": "转交所有者",
"newOwnerId": newOwnerID,
}
if nodeID != "" {
payload["nodeId"] = nodeID
} else {
payload["workspaceId"] = workspaceID
}
return deps.Out.PrintJSON(payload)
}
deps.Out.PrintKeyValue("操作", "转交所有者")
deps.Out.PrintKeyValue("新所有者", newOwnerID)
return nil
}
reserveRole := mustGetFlag(cmd, "reserve-role")
recursive, _ := cmd.Flags().GetBool("recursive")
target := nodeID
if target == "" {
target = workspaceID
}
toolArgs := map[string]any{"newOwnerId": newOwnerID}
if nodeID != "" {
toolArgs["nodeId"] = nodeID
} else {
toolArgs["workspaceId"] = workspaceID
}
if reserveRole != "" {
toolArgs["reserveOldOwnerRole"] = reserveRole
}
if cmd.Flags().Changed("recursive") {
toolArgs["recursiveChange"] = recursive
}
return callMCPToolOnServer("doc", "transfer_owner", toolArgs)
},
}
DeclareLeafMetadata(drivePermTransferOwnerCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "transfer_owner",
CanonicalPath: "drive.transfer_owner",
CLIPath: "drive permission transfer-owner",
PrimaryCLIPath: "drive permission transfer-owner",
},
Description: "转交文档或知识库所有者给指定用户(不可逆)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "转交文档或知识库所有者给指定用户(不可逆)",
UseWhen: []string{"用户明确要求转交文档/知识库所有权"},
AvoidWhen: []string{"普通协作权限变更用 permission add/update/remove"},
Examples: []string{"dws drive permission transfer-owner --node <DOC_ID> --new-owner <userId> --reserve-role EDITOR --recursive=false --format json"},
},
},
})
drivePermTransferOwnerCmd.Flags().String("node", "", "目标节点 ID 或 URL(与 --workspace 二选一)")
drivePermTransferOwnerCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(与 --node 二选一)")
drivePermTransferOwnerCmd.Flags().String("new-owner", "", "新所有者的用户 userId (必填)")
drivePermTransferOwnerCmd.Flags().String("reserve-role", "", "转交后原所有者保留角色: MANAGER / EDITOR / DOWNLOADER / READER / NONE")
drivePermTransferOwnerCmd.Flags().Bool("recursive", false, "是否递归变更所有子节点的所有者")
drivePermApplyInfoCmd := &cobra.Command{
Use: "apply-info",
Short: "查询节点可申请的角色与审批人",
Example: ` dws drive permission apply-info --node DOC_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "query_permission_apply_info", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(drivePermApplyInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "query_permission_apply_info",
CanonicalPath: "drive.query_permission_apply_info",
CLIPath: "drive permission apply-info",
PrimaryCLIPath: "drive permission apply-info",
},
Description: "查询节点可申请的权限角色列表与审批人列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询节点可申请的权限角色列表与审批人列表",
UseWhen: []string{"无权限访问文档时,先查可申请角色与审批人"},
AvoidWhen: []string{"实际发起申请用 apply_permission"},
Examples: []string{"dws drive permission apply-info --node <DOC_ID> --format json"},
},
},
})
drivePermApplyInfoCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd := &cobra.Command{
Use: "apply",
Short: "发起权限申请",
Long: `向指定节点的审批人发起权限申请。建议先用 apply-info 获取可申请角色与审批人。
注意: 本命令会真实通知审批人,Agent 必须先获得用户明确同意后再执行。`,
Example: ` dws drive permission apply --node DOC_ID --role READER --users uid1
dws drive permission apply --node DOC_ID --role EDITOR --users uid1,uid2 --reason "需要编辑该文档"`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{
"nodeId": nodeID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"receivers": userIds,
}
if v := mustGetFlag(cmd, "notify-mode"); v != "" {
toolArgs["notifyMode"] = v
}
if v := mustGetFlag(cmd, "reason"); v != "" {
toolArgs["reason"] = v
}
return callMCPToolOnServer("drive", "apply_permission", toolArgs)
},
}
DeclareLeafMetadata(drivePermApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "apply_permission",
CanonicalPath: "drive.apply_permission",
CLIPath: "drive permission apply",
PrimaryCLIPath: "drive permission apply",
},
Description: "向审批人发起文档权限申请(会真实通知审批人)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "向审批人发起文档权限申请(会真实通知审批人)",
UseWhen: []string{"用户确认要为无权限文档发起权限申请"},
AvoidWhen: []string{"先用 apply-info 查可申请角色与审批人;未经用户确认不得自行提交"},
Examples: []string{"dws drive permission apply --node <DOC_ID> --role READER --users <审批人userId> --format json"},
},
},
})
drivePermApplyCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermApplyCmd.Flags().String("role", "", "申请的角色: EDITOR / DOWNLOADER / READER (必填)")
drivePermApplyCmd.Flags().String("users", "", "审批人 userId 列表,逗号分隔 (必填)")
drivePermApplyCmd.Flags().String("user", "", "")
_ = drivePermApplyCmd.Flags().MarkHidden("user")
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
}
for _, c := range driveNodeAliasCmds {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
c.Flags().String("doc-id", "", "")
c.Flags().String("file-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
// --name/--title 隐藏别名
driveRenameCmd.Flags().String("title", "", "")
_ = driveRenameCmd.Flags().MarkHidden("title")
// ── drive recycle 子命令组 ──
recycleCmd := newGroupCommand(&cobra.Command{
Use: "recycle",
Short: "钉盘回收站管理",
Long: `管理钉盘回收站:查看回收站列表、还原回收项。`,
RunE: groupRunE,
})
recycleListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查看回收站文件列表",
Example: ` dws drive recycle list
dws drive recycle list --space-id 12345 --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
toolArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
toolArgs["nextCursor"] = v
}
return callMCPTool("list_recycle_items", toolArgs)
},
}
DeclareLeafMetadata(recycleListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "recycle_list",
CanonicalPath: "drive.recycle_list",
CLIPath: "drive recycle list",
PrimaryCLIPath: "drive recycle list",
},
Description: "查看回收站文件列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查看回收站文件列表",
UseWhen: []string{"用户要查看钉盘回收站里有哪些已删文件时"},
AvoidWhen: []string{
"要从回收站还原改用 dws drive recycle restore",
"要删除进回收站改用 dws drive delete",
},
Examples: []string{
"dws drive recycle list --format json",
"dws drive recycle list --space-id 12345 --limit 10 --format json",
},
},
},
})
recycleListCmd.Flags().String("space-id", "", "钉盘空间 ID (选填,不传则返回所有空间)")
recycleListCmd.Flags().Int("limit", 0, "返回条数上限 (默认20,最大50)")
recycleListCmd.Flags().String("cursor", "", "分页游标")
recycleRestoreCmd := &cobra.Command{
Use: "restore",
Short: "还原回收站中的文件",
Example: ` dws drive recycle restore --id RECYCLE_ITEM_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
recycleItemID, err := mustFlagOrFallback(cmd, "id")
if err != nil {
return err
}
return callMCPTool("restore_recycle_item", map[string]any{
"recycleItemId": recycleItemID,
})
},
}
DeclareLeafMetadata(recycleRestoreCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "recycle_restore",
CanonicalPath: "drive.recycle_restore",
CLIPath: "drive recycle restore",
PrimaryCLIPath: "drive recycle restore",
},
Description: "还原回收站中的文件",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "还原回收站中的文件",
UseWhen: []string{"用户要从回收站还原指定回收项(已从 recycle list 拿到 id)时"},
AvoidWhen: []string{
"尚未确认还原哪一项时先 dws drive recycle list",
"要删除文件改用 dws drive delete,不要用 restore",
},
Examples: []string{"dws drive recycle restore --id <recycleItemId> --format json"},
},
},
})
recycleRestoreCmd.Flags().String("id", "", "回收项 ID (必填,从 recycle list 获取)")
recycleCmd.AddCommand(recycleListCmd, recycleRestoreCmd)
// ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)──
// folder create → dws wiki node create --type folder
driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE})
driveFolderCreateCmd := &cobra.Command{
Use: "create",
Short: "创建文件夹(deprecated)",
Long: `已废弃。请使用 'dws wiki node create --workspace <workspaceId> --name <name> --type folder'。`,
RunE: func(cmd *cobra.Command, args []string) error {
deps.Out.PrintWarning("⚠️ 'dws drive folder create' is deprecated, use 'dws wiki node create --workspace <workspaceId> --name <name> --type folder' instead.")
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
return err
}
toolArgs := map[string]any{
"name": flagOrFallback(cmd, "name", "title"),
}
if v := docFolderFlag(cmd); v != "" {
toolArgs["folderId"] = v
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
toolArgs["workspaceId"] = v
}
return callMCPToolOnServer("doc", "create_folder", toolArgs)
},
}
driveFolderCreateCmd.Flags().String("name", "", "文件夹名称(必填)")
driveFolderCreateCmd.Flags().String("title", "", "")
_ = driveFolderCreateCmd.Flags().MarkHidden("title")
driveFolderCreateCmd.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
driveFolderCreateCmd.Flags().String("workspace", "", "目标知识库 ID")
driveFolderCmd.AddCommand(driveFolderCreateCmd)
// ── drive publish (文件互联网公开发布管理) ──
drivePublishCmd := newGroupCommand(&cobra.Command{
Use: "publish",
Short: "文件互联网公开发布管理",
Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`,
RunE: groupRunE,
})
drivePublishSetCmd := &cobra.Command{
Use: "set",
Short: "[危险] 设置文件为互联网公开",
Long: `[危险] 将文件设置为互联网公开发布。公开后任何人通过链接即可访问,无需登录钉钉。
操作者需要是该文件的管理员或拥有者。执行前需要确认,或传入 --yes 跳过确认。
公开权限 (--permission): READER(仅可查看) / DOWNLOADER(可查看和下载,默认) / EDITOR(可编辑)`,
Example: ` dws drive publish set --node <fileId> --yes
dws drive publish set --node <fileId> --permission READER --yes
dws drive publish set --node <fileId> # 交互式确认`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id")
if err != nil {
return err
}
toolArgs := map[string]any{
"fileId": nodeID,
"published": true,
}
if v := mustGetFlag(cmd, "permission"); v != "" {
toolArgs["publishPermission"] = v
}
return callMCPTool("set_file_publish", toolArgs)
},
}
DeclareLeafMetadata(drivePublishSetCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(cmd *cobra.Command, args []string) error {
_, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id")
return err
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "publish_set",
CanonicalPath: "drive.publish_set",
CLIPath: "drive publish set",
PrimaryCLIPath: "drive publish set",
},
Description: "开启文件的互联网公开发布",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "开启文件的互联网公开发布",
UseWhen: []string{"用户明确要求将文件设为互联网公开(任何人凭链接可访问,无需登录)时"},
AvoidWhen: []string{
"只查公开状态用 publish get;要关闭公开用 publish unset",
"目标文件或公开权限范围未确认时不要开启",
"只要企业内部同事权限用 permission add,不要用互联网公开",
},
Examples: []string{
"dws drive publish set --node <fileId> --format json",
"dws drive publish set --node <fileId> --permission READER --format json",
},
},
},
})
drivePublishSetCmd.Flags().String("node", "", "目标文件 ID (dentryUuid) 或 URL (必填)")
drivePublishSetCmd.Flags().String("permission", "", "公开后的权限: READER / DOWNLOADER(默认) / EDITOR")
drivePublishUnsetCmd := &cobra.Command{
Use: "unset",
Aliases: []string{"off", "close"},
Short: "[危险] 关闭文件互联网公开",
Long: `[危险] 关闭文件的互联网公开发布。关闭后外部用户将无法再通过链接访问。执行前需要确认,或传入 --yes 跳过确认。`,
Example: ` dws drive publish unset --node <fileId> --yes
dws drive publish unset --node <fileId> # 交互式确认`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id")
if err != nil {
return err
}
return callMCPTool("set_file_publish", map[string]any{
"fileId": nodeID,
"published": false,
})
},
}
DeclareLeafMetadata(drivePublishUnsetCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(cmd *cobra.Command, args []string) error {
_, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id")
return err
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "publish_unset",
CanonicalPath: "drive.publish_unset",
CLIPath: "drive publish unset",
PrimaryCLIPath: "drive publish unset",
},
Description: "关闭文件的互联网公开发布",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "关闭文件的互联网公开发布",
UseWhen: []string{"用户明确要求关闭文件的互联网公开发布,使外部链接失效时"},
AvoidWhen: []string{
"只查状态用 publish get;要开启用 publish set",
"目标文件未确认时不要关闭",
},
Examples: []string{"dws drive publish unset --node <fileId> --format json"},
},
},
})
drivePublishUnsetCmd.Flags().String("node", "", "目标文件 ID (dentryUuid) 或 URL (必填)")
drivePublishGetCmd := &cobra.Command{
Use: "get",
Aliases: []string{"status"},
Short: "查询文件公开发布状态",
Long: `查询文件当前是否处于互联网公开发布状态,以及公开发布的权限设置。`,
Example: ` dws drive publish get --node <fileId>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id")
if err != nil {
return err
}
return callMCPTool("get_file_publish_status", map[string]any{
"fileId": nodeID,
})
},
}
DeclareLeafMetadata(drivePublishGetCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "publish_get",
CanonicalPath: "drive.publish_get",
CLIPath: "drive publish get",
PrimaryCLIPath: "drive publish get",
},
Description: "查询文件当前是否处于互联网公开发布状态",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文件当前是否处于互联网公开发布状态",
UseWhen: []string{"查询文件是否已互联网公开发布及公开权限(READER/DOWNLOADER/EDITOR)时"},
AvoidWhen: []string{
"要开启公开改用 dws drive publish set(需确认)",
"要关闭公开改用 dws drive publish unset(需确认)",
},
Examples: []string{"dws drive publish get --node <fileId> --format json"},
},
},
})
drivePublishGetCmd.Flags().String("node", "", "目标文件 ID (dentryUuid) 或 URL (必填)")
// publish 子命令 --node 隐藏别名
for _, c := range []*cobra.Command{drivePublishSetCmd, drivePublishUnsetCmd, drivePublishGetCmd} {
c.Flags().String("url", "", "")
c.Flags().String("id", "", "")
c.Flags().String("node-id", "", "")
c.Flags().String("file-id", "", "")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("file-id")
}
drivePublishCmd.AddCommand(drivePublishSetCmd, drivePublishUnsetCmd, drivePublishGetCmd)
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
} {
RegisterCrossProductAliases(cmd)
}
for _, parent := range []*cobra.Command{drivePermissionCmd, recycleCmd, drivePublishCmd} {
for _, child := range parent.Commands() {
RegisterCrossProductAliases(child)
}
}
// ── recent 命令:获取最近访问/编辑的文档列表 ──
driveRecentCmd := &cobra.Command{
Use: "recent",
Short: "获取最近访问/编辑的文档列表",
Long: `获取当前用户最近访问或编辑过的文档列表。
支持按文档类型、操作类型、创建人、所属组织过滤。所有过滤条件均为可选,不传则不过滤。
操作类型 (--operate-type):
0 最近访问(含打开+编辑)(默认)
1 最近编辑
不传默认仅返回最近访问(0)
创建人类型 (--creator-type):
0 全部 (默认)
1 我创建的
2 他人创建的`,
Example: ` dws drive recent
dws drive recent --operate-type 1
dws drive recent --creator-type 1 --limit 10
dws drive recent --file-types 0,1 --operate-type 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetIntSlice("file-types"); len(v) > 0 {
toolArgs["fileTypes"] = v
}
if v, _ := cmd.Flags().GetIntSlice("operate-type"); len(v) > 0 {
toolArgs["operateTypes"] = v
}
if cmd.Flags().Changed("creator-type") {
if v, _ := cmd.Flags().GetInt("creator-type"); v >= 0 {
toolArgs["creatorType"] = v
}
}
if v, _ := cmd.Flags().GetIntSlice("org-ids"); len(v) > 0 {
toolArgs["resourceOrgIds"] = v
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
} else if v, _ := cmd.Flags().GetInt("max-results"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
return callMCPToolOnServer("doc", "get_recent_list", toolArgs)
},
}
DeclareLeafMetadata(driveRecentCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "recent",
CanonicalPath: "drive.recent",
CLIPath: "drive recent",
PrimaryCLIPath: "drive recent",
},
Description: "获取当前用户最近访问或编辑过的文档列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户最近访问或编辑过的文档列表",
UseWhen: []string{
"用户要看最近访问或最近编辑的文档列表时(默认最近访问 operate-type=0)",
"需要按我创建/他人创建或文档类型过滤最近项时",
},
AvoidWhen: []string{
"按关键词全局搜文件改用 dws drive search",
"浏览某目录内容改用 dws drive list",
},
Examples: []string{
"dws drive recent --format json",
"dws drive recent --operate-type 1 --format json",
},
},
},
})
driveRecentCmd.Flags().IntSlice("file-types", nil, "按文档类型过滤,逗号分隔 (参考 RecentAccessType 枚举)")
driveRecentCmd.Flags().IntSlice("operate-type", nil, "按操作类型过滤: 0=最近访问(默认), 1=最近编辑; 不传默认仅最近访问")
driveRecentCmd.Flags().Int("creator-type", 0, "按创建人过滤: 0=全部, 1=我创建, 2=他人创建")
driveRecentCmd.Flags().IntSlice("org-ids", nil, "按资源所属组织 ID 过滤,逗号分隔")
driveRecentCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 20)")
driveRecentCmd.Flags().Int("max-results", 0, "")
_ = driveRecentCmd.Flags().MarkHidden("max-results")
driveRecentCmd.Flags().String("cursor", "", "分页游标 (从上次结果的 nextCursor 获取)")
driveRecentCmd.Flags().String("page-token", "", "")
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := newGroupCommand(&cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
})
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
Example: ` dws drive star add --node <nodeId_or_URL>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "mark_star", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(driveStarAddCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "mark_star",
CanonicalPath: "drive.mark_star",
CLIPath: "drive star add",
PrimaryCLIPath: "drive star add",
},
Description: "收藏文档/文件到当前用户收藏列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "收藏文档/文件到当前用户收藏列表",
UseWhen: []string{"用户说 收藏这个文档/加个收藏/标星"},
AvoidWhen: []string{"取消收藏用 unmark_star;查看收藏列表用 get_star_list"},
Examples: []string{"dws drive star add --node <nodeId> --format json"},
},
},
})
driveStarAddCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarRemoveCmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "取消收藏文档",
Example: ` dws drive star remove --node <nodeId_or_URL>`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "unmark_star", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(driveStarRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "unmark_star",
CanonicalPath: "drive.unmark_star",
CLIPath: "drive star remove",
PrimaryCLIPath: "drive star remove",
},
Description: "将文档/文件从当前用户收藏列表移除",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "将文档/文件从当前用户收藏列表移除",
UseWhen: []string{"用户说 取消收藏/去掉收藏/不收藏了"},
AvoidWhen: []string{"添加收藏用 mark_star"},
Examples: []string{"dws drive star remove --node <nodeId> --format json"},
},
},
})
driveStarRemoveCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
driveStarListCmd := &cobra.Command{
Use: "list",
Short: "获取收藏列表",
Example: ` dws drive star list
dws drive star list --content-types doc,sheet --limit 10`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["limit"] = v
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
toolArgs["cursor"] = v
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
toolArgs["orderBy"] = v
}
if v, _ := cmd.Flags().GetString("sort"); v != "" {
toolArgs["sortType"] = v
}
if v, _ := cmd.Flags().GetStringSlice("resource-types"); len(v) > 0 {
toolArgs["supportResourceTypes"] = v
}
if v, _ := cmd.Flags().GetStringSlice("content-types"); len(v) > 0 {
toolArgs["contentTypes"] = v
}
return callMCPToolOnServer("drive", "get_star_list", toolArgs)
},
}
DeclareLeafMetadata(driveStarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_star_list",
CanonicalPath: "drive.get_star_list",
CLIPath: "drive star list",
PrimaryCLIPath: "drive star list",
},
Description: "获取当前用户的收藏列表,支持分页与按内容类型筛选",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户的收藏列表,支持分页与按内容类型筛选",
UseWhen: []string{"用户说 我的收藏/收藏列表/收藏了哪些文档"},
AvoidWhen: []string{"操作单个收藏用 mark_star/unmark_star"},
Examples: []string{
"dws drive star list --format json",
"dws drive star list --content-types doc,sheet --limit 10 --format json",
},
},
},
})
driveStarListCmd.Flags().Int("limit", 0, "每页条数 (默认 20,最大 20)")
driveStarListCmd.Flags().String("cursor", "", "分页游标")
driveStarListCmd.Flags().String("order-by", "", "排序字段: createTime")
driveStarListCmd.Flags().String("sort", "", "排序方向: asc|desc")
driveStarListCmd.Flags().StringSlice("resource-types", nil, "资源大类: DENTRY, TEAM, WORKSPACE")
driveStarListCmd.Flags().StringSlice("content-types", nil, "内容类型: doc,sheet,ppt,whiteboard,mind,notable,pdf,other,folder")
driveStarCmd.AddCommand(driveStarAddCmd, driveStarRemoveCmd, driveStarListCmd)
// ── drive cover (获取节点封面地址) ──
driveCoverCmd := &cobra.Command{
Use: "cover",
Short: "获取节点封面地址",
Example: " dws drive cover --node <dentryUuid>",
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
return callMCPToolOnServer("drive", "get_cover", map[string]any{"nodeId": nodeID})
},
}
DeclareLeafMetadata(driveCoverCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "get_cover",
CanonicalPath: "drive.get_cover",
CLIPath: "drive cover",
PrimaryCLIPath: "drive cover",
},
Description: "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取节点封面图片地址(文档首图/图片缩略图/类型图标)",
UseWhen: []string{"用户说 封面/封面图/缩略图/预览图"},
AvoidWhen: []string{"设置文档封面用 doc style cover set"},
Examples: []string{"dws drive cover --node <dentryUuid> --format json"},
},
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚文件到指定历史版本",
Long: `将指定文件回滚到某个历史版本。仅支持普通文件(Word、Excel、PDF、图片等)。
在线文档请用 dws doc version revert,在线表格请用 dws sheet version revert。`,
Example: ` dws drive revert --node <dentryUuid> --version 3 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
versionNum, err := cmd.Flags().GetInt("version")
if err != nil || versionNum <= 0 {
return fmt.Errorf("flag --version is required and must be a positive integer")
}
return callMCPToolOnServer("drive", "revert_file_version", map[string]any{
"nodeId": nodeID,
"version": versionNum,
})
},
}
DeclareLeafMetadata(driveRevertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "revert_file_version",
CanonicalPath: "drive.revert_file_version",
CLIPath: "drive revert",
PrimaryCLIPath: "drive revert",
},
Description: "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "回滚普通文件到指定历史版本(生成新最新版本,历史不丢失)",
UseWhen: []string{"用户说 回滚版本/恢复到某个版本/版本回退,且目标是普通文件"},
AvoidWhen: []string{"在线文档回滚用 doc version revert;在线表格用 sheet version revert"},
Examples: []string{"dws drive revert --node <dentryUuid> --version 3 --format json"},
},
},
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveStatusCmd := &cobra.Command{
Use: "status",
Short: "比较本地文件夹与钉盘文件夹的差异",
Long: `比较本地文件夹与钉盘文件夹的差异:本地取 --local-folder(绝对路径),钉盘取
--remote-folder(文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速
modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 相对各自根目录。
输出五类差异:
new_local 仅本地存在
new_remote 仅钉盘存在
modified 两侧都存在且本次检测判定为已变更
unchanged 两侧都存在且本次检测判定为未变更
unknown 两侧都存在,但 exact 模式下远端无可靠 MD5、无法核对内容(不判 unchanged/modified)
只比对钉盘 type=file 的二进制文件(跳过在线文档与快捷方式);本地只比对常规文件。`,
Example: ` dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick`,
RunE: runDriveStatus,
}
driveStatusCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
driveStatusCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
driveStatusCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
driveStatusCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
drivePullCmd := &cobra.Command{
Use: "pull",
Short: "把钉盘文件夹单向镜像到本地(Drive → 本地)",
Long: `递归下载钉盘 --remote-folder 文件夹下所有 type=file 的文件到本地
--local-folder 对应路径(子目录自动创建),单向、文件级镜像。
已存在的本地文件按 --if-exists 处理:
skip 默认,安全:本地已存在则保持不动,只新增
smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载
overwrite 总是下载覆盖(Drive 作为权威源)
该命令会写入本地文件系统,执行前需要用户确认;非交互环境先用 --dry-run
预览,确认后以相同参数追加 --yes 执行。
输出 summary(downloaded/skipped/failed)与逐文件 items。
若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx`,
RunE: runDrivePull,
}
drivePullCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
drivePullCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
drivePullCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
drivePullCmd.Flags().String("if-exists", "skip", "本地文件已存在时的策略: skip|smart|overwrite;命令会写本地,执行需确认 (可选)")
drivePushCmd := &cobra.Command{
Use: "push",
Short: "把本地文件夹单向镜像到钉盘(本地 → Drive)",
Long: `递归把本地 --local-folder 下的文件与子目录(含空目录)镜像到钉盘
--remote-folder 文件夹:缺失的目录按需创建(已存在则复用,不重建),文件按
--if-exists 处理。文件级镜像——只新增/覆盖,不删除远端多余文件。
已存在的远端文件按 --if-exists 处理:
skip 默认,安全:已存在则保持不动,只新增
smart 增量同步:远端 modified_time 已 ≥ 本地时跳过,否则走覆盖路径
overwrite 覆盖远端同名文件
该命令会写入钉盘,执行前需要用户确认;非交互环境先用 --dry-run 预览,
确认后以相同参数追加 --yes 执行。
输出 summary(uploaded/skipped/failed,uploaded 含新建与覆盖)与逐条 items
(含 folder_created)。若有文件失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
Example: ` dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists overwrite`,
RunE: runDrivePush,
}
drivePushCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
drivePushCmd.Flags().String("remote-folder", "", "钉盘目标文件夹 ID (dentryUuid) (必填)")
drivePushCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite;命令会写钉盘,执行需确认 (可选)")
driveSyncCmd := &cobra.Command{
Use: "sync",
Short: "本地文件夹与钉盘文件夹双向同步(本地 ⇄ Drive)",
Long: `把本地 --local-folder 与钉盘 --remote-folder 做文件级双向同步:先按精确 MD5
(默认)或快速 modified_time(--quick)算出差异,再按方向执行:
new_local 仅本地存在 → 上传到钉盘(缺失的远端目录按需创建)
new_remote 仅钉盘存在 → 下载到本地
modified 两侧都变更 → 按 --on-conflict 解决
unchanged 两侧一致 → 不动
两侧都变更时的 --on-conflict 策略:
skip 默认,两侧都不动并保留两边内容
remote-wins 拉取远端覆盖本地
local-wins 上传本地覆盖远端
keep-both 本地文件改名保留,再拉取远端到原路径
ask 交互式逐个询问
exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。
文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/
skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。
该命令会同时写入本地与钉盘,执行前需要用户确认;非交互环境先用 --dry-run
预览,确认后以相同参数追加 --yes 执行。`,
Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict local-wins
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick --on-conflict keep-both`,
RunE: runDriveSync,
}
driveSyncCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
driveSyncCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
driveSyncCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
driveSyncCmd.Flags().String("on-conflict", "skip", "两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask;命令会写双端,执行需确认 (可选)")
driveSyncCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
DeclareLeafMetadata(driveStatusCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "folder_status",
CanonicalPath: "drive.folder_status",
CLIPath: "drive status",
PrimaryCLIPath: "drive status",
},
Description: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
Result: driveFolderStatusResultSpec(),
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, walks the local tree, and compares both sides by MD5 or modification time; no single pinned RPC represents the diff.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
UseWhen: []string{"需要先看清本地与钉盘之间哪些文件新增、变更或一致,再决定拉取还是推送时"},
AvoidWhen: []string{"只要单个文件的元数据用 drive info;要真正传输文件用 drive pull / push / sync"},
Examples: []string{
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>",
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick",
},
},
Parameters: []contract.ParamDecl{
{Name: "local-folder", Required: boolPtr(true)},
{Name: "remote-folder", Required: boolPtr(true)},
},
},
})
DeclareLeafMetadata(drivePullCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "folder_pull",
CanonicalPath: "drive.folder_pull",
CLIPath: "drive pull",
PrimaryCLIPath: "drive pull",
},
Description: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderPullResultSpec(),
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files and then downloads each file through drive/download_file plus an HTTP GET into a temporary file committed by an atomic rename; no single pinned RPC represents the mirror.",
},
Selection: contract.SelectionSpec{
AgentSummary: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
UseWhen: []string{"需要把整个钉盘文件夹拉到本地目录时"},
AvoidWhen: []string{"只下载单个文件用 drive download;要把本地推到钉盘用 drive push;要双向对齐用 drive sync"},
Examples: []string{
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>",
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
Parameters: []contract.ParamDecl{
{Name: "local-folder", Required: boolPtr(true)},
{Name: "remote-folder", Required: boolPtr(true)},
},
},
})
DeclareLeafMetadata(drivePushCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "folder_push",
CanonicalPath: "drive.folder_push",
CLIPath: "drive push",
PrimaryCLIPath: "drive push",
},
Description: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderPushResultSpec(),
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, creates missing folders through drive/create_folder, and uploads each file through drive/get_upload_info plus an HTTP PUT and drive/commit_upload; no single pinned RPC represents the mirror.",
},
Selection: contract.SelectionSpec{
AgentSummary: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
UseWhen: []string{"需要把整个本地目录推送到钉盘文件夹时"},
AvoidWhen: []string{"只上传单个文件用 drive upload;要把钉盘拉到本地用 drive pull;要双向对齐用 drive sync"},
Examples: []string{
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>",
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
Parameters: []contract.ParamDecl{
{Name: "local-folder", Required: boolPtr(true)},
{Name: "remote-folder", Required: boolPtr(true)},
},
},
})
DeclareLeafMetadata(driveSyncCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "folder_sync",
CanonicalPath: "drive.folder_sync",
CLIPath: "drive sync",
PrimaryCLIPath: "drive sync",
},
Description: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderSyncResultSpec(),
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite workflow: the command computes the same diff as drive status and then resolves it in both directions through drive/download_file, drive/create_folder, drive/get_upload_info and drive/commit_upload according to --on-conflict; no single pinned RPC represents the bidirectional sync.",
},
Selection: contract.SelectionSpec{
AgentSummary: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
UseWhen: []string{"需要让本地目录与钉盘文件夹互相补齐时"},
AvoidWhen: []string{"只需单方向镜像用 drive pull / push;只想看差异用 drive status"},
Examples: []string{
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>",
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict remote-wins --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
Parameters: []contract.ParamDecl{
{Name: "local-folder", Required: boolPtr(true)},
{Name: "remote-folder", Required: boolPtr(true)},
},
},
})
driveCmd.AddCommand(
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
newDriveFileCommentCmd(),
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
driveUploadInfoCmd,
driveCommitCmd,
driveUploadCmd,
driveDeleteCmd,
driveSearchCmd,
driveRecentCmd,
// 文档空间代理命令(Phase 1)
driveCopyCmd,
driveMoveCmd,
driveRenameCmd,
driveStatsCmd,
driveShortcutCmd,
drivePermissionCmd,
drivePublishCmd,
recycleCmd,
// 同步命令:status / pull / push / sync
driveStatusCmd,
drivePullCmd,
drivePushCmd,
driveSyncCmd,
driveStarCmd,
driveCoverCmd,
driveRevertCmd,
// deprecated 兼容命令(Phase 2)— 隐藏,保留向后兼容
driveFolderCmd,
)
// deprecated Phase 2 命令从 help 中隐藏(仍可执行,保留向后兼容)
driveFolderCmd.Hidden = true
return driveCmd
}
// driveInfoWithDocFallback 获取钉盘文件元数据,若检测到文件属于钉钉文档,
// 自动跟进调用 doc info 获取更准确的文档信息并合并输出。
//
// 判断依据:MCP 返回 result.message 包含"钉钉文档"关键词时,说明该文件
// 是在线文档/表格/脑图等,钉盘接口返回的元数据(如文件名称)可能不准确。
func driveInfoWithDocFallback(fileID string, driveArgs map[string]any) error {
ctx := context.Background()
// Step 1: 调用 drive get_file_info
driveText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", driveArgs)
if err != nil {
return err
}
// Step 2: 解析返回,检查是否需要跟进 doc info
var driveResp map[string]any
if err := json.Unmarshal([]byte(driveText), &driveResp); err != nil {
// 解析失败,直接原样输出
deps.Out.PrintRaw(driveText)
return nil
}
driveResult, _ := driveResp["result"].(map[string]any)
if driveResult == nil {
return deps.Out.PrintJSON(driveResp)
}
message, _ := driveResult["message"].(string)
extension, _ := driveResult["extension"].(string)
if !strings.Contains(message, "钉钉文档") && !isDingTalkDocExtension(extension) {
// 普通钉盘文件,直接输出 drive info 结果
return deps.Out.PrintJSON(driveResp)
}
// Step 3: 文件属于钉钉文档,自动跟进调用 doc info
nodeID, _ := driveResult["fileId"].(string)
if nodeID == "" {
nodeID = fileID
}
docText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_info", map[string]any{
"nodeId": nodeID,
})
if err != nil {
// doc info 调用失败,回退输出 drive info 的结果(附加提示)
deps.Out.PrintInfo("提示: 自动获取文档详情失败,以下为钉盘元数据(文档名称可能不准确)")
return deps.Out.PrintJSON(driveResp)
}
var docResp map[string]any
if err := json.Unmarshal([]byte(docText), &docResp); err != nil {
deps.Out.PrintInfo("提示: 自动获取文档详情失败,以下为钉盘元数据(文档名称可能不准确)")
return deps.Out.PrintJSON(driveResp)
}
// Step 4: 合并输出 — 以 doc info 为主体,补充 drive info 的独有字段
// doc info 可能返回扁平结构(无 result 包裹层)或带 result 包裹层
docResult, hasResultWrapper := docResp["result"].(map[string]any)
if !hasResultWrapper {
// doc info 返回扁平结构,整个 docResp 就是文档信息
docResult = docResp
}
if len(docResult) == 0 {
return deps.Out.PrintJSON(driveResp)
}
// 从 drive info 补充 doc info 中没有的字段
driveOnlyFields := []string{"dentryId", "path", "fileSize", "extension", "type"}
for _, field := range driveOnlyFields {
if val, ok := driveResult[field]; ok {
current, exists := docResult[field]
if (!exists || current == nil) && val != nil {
docResult[field] = val
}
}
}
// 统一输出格式:如果原始 doc 响应是扁平结构,包装成与 drive info 一致的格式
if !hasResultWrapper {
return deps.Out.PrintJSON(map[string]any{
"result": docResult,
"success": true,
})
}
return deps.Out.PrintJSON(docResp)
}
// sanitizeFileName removes all directory components and NUL bytes so remote
// metadata can never escape a caller-owned output or temporary directory.
func sanitizeFileName(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(name)
if name == "." || name == ".." || name == "/" || name == "" {
return "unnamed"
}
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "unnamed"
}
return name
}
// extractFileNameFromResponse extracts the fileName field from MCP download_file response JSON.
// Returns empty string if not found.
func extractFileNameFromResponse(text string) string {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return ""
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if name, ok := data["fileName"].(string); ok && name != "" {
return sanitizeFileName(name)
}
return ""
}
// isDingTalkDocExtension 判断文件扩展名是否属于钉钉文档类型。
// 钉钉文档类型包括:adoc(在线文档)、axls(在线表格)、amind(脑图)、adraw(画图)。
func isDingTalkDocExtension(ext string) bool {
switch strings.ToLower(ext) {
case "adoc", "axls", "amind", "adraw":
return true
default:
return false
}
}
// uploadToDrive performs the complete drive upload workflow with explicit
// server routing. overwriteFileID changes both MCP steps to overwrite mode and
// deliberately excludes parentId.
func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int64, spaceID, folderID, overwriteFileID, mimeType string) error {
step1Args := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
}
if spaceID != "" {
step1Args["spaceId"] = spaceID
}
if mimeType != "" {
step1Args["mimeType"] = mimeType
}
if overwriteFileID != "" {
step1Args["overwriteFileId"] = overwriteFileID
} else if folderID != "" {
step1Args["parentId"] = folderID
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
if err != nil {
return err
}
// HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次)
uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args)
}, filePath, fileSize)
if err != nil {
return err
}
commitArgs := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"uploadId": uploadID,
}
if spaceID != "" {
commitArgs["spaceId"] = spaceID
}
if overwriteFileID != "" {
commitArgs["overwriteFileId"] = overwriteFileID
} else if folderID != "" {
commitArgs["parentId"] = folderID
}
return callMCPToolOnServer("drive", "commit_upload", commitArgs)
}
// uploadToDocSpace performs the complete document-space upload workflow with
// explicit server routing. overwriteNodeID changes both MCP steps to overwrite
// mode and deliberately excludes folderId.
func uploadToDocSpace(ctx context.Context, filePath, fileName string, fileSize int64, workspaceID, folderID, overwriteNodeID string, convert bool) error {
step1Args := map[string]any{}
if workspaceID != "" {
step1Args["workspaceId"] = workspaceID
}
if overwriteNodeID != "" {
step1Args["overwriteNodeId"] = overwriteNodeID
step1Args["name"] = fileName
} else if folderID != "" {
step1Args["folderId"] = folderID
}
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_file_upload_info", step1Args)
if err != nil {
return err
}
resourceURL, uploadKey, headers, err := parseUploadInfo(text)
if err != nil {
return err
}
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitArgs := map[string]any{
"uploadKey": uploadKey,
"name": fileName,
"fileSize": float64(fileSize),
}
if workspaceID != "" {
commitArgs["workspaceId"] = workspaceID
}
if overwriteNodeID != "" {
commitArgs["overwriteNodeId"] = overwriteNodeID
} else if folderID != "" {
commitArgs["folderId"] = folderID
}
if convert {
commitArgs["convertToOnlineDoc"] = true
}
return callMCPToolOnServer("doc", "commit_uploaded_file", commitArgs)
}
func downloadFromDrive(ctx context.Context, fileID, spaceID string) (content, filename string, err error) {
args := map[string]any{"fileId": fileID}
if spaceID != "" {
args["spaceId"] = spaceID
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file", args)
if err != nil {
return "", "", err
}
resourceURL, headers, err := parseDownloadInfo(text)
if err != nil {
return "", "", err
}
filename = resolveDownloadFilename(text, resourceURL)
if filename == "" || filename == "unnamed" {
filename = "download.md"
}
tmpDir, err := os.MkdirTemp("", "dws-drive-download-*")
if err != nil {
return "", "", fmt.Errorf("创建临时目录失败: %w", err)
}
defer os.RemoveAll(tmpDir)
destPath := filepath.Join(tmpDir, sanitizeFileName(filename))
if err := httpGetFile(ctx, resourceURL, headers, destPath); err != nil {
return "", "", err
}
data, err := os.ReadFile(destPath)
if err != nil {
return "", "", fmt.Errorf("读取下载内容失败: %w", err)
}
return string(data), sanitizeFileName(filename), nil
}
func downloadFromDoc(ctx context.Context, nodeID string) (content, filename string, err error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "download_file", map[string]any{"nodeId": nodeID})
if err != nil {
return "", "", err
}
resourceURL, headers, err := parseDownloadInfo(text)
if err != nil {
return "", "", err
}
filename = resolveDownloadFilename(text, resourceURL)
if filename == "" || filename == "unnamed" {
filename = "download.md"
}
tmpDir, err := os.MkdirTemp("", "dws-doc-download-*")
if err != nil {
return "", "", fmt.Errorf("创建临时目录失败: %w", err)
}
defer os.RemoveAll(tmpDir)
destPath := filepath.Join(tmpDir, sanitizeFileName(filename))
if err := httpGetFile(ctx, resourceURL, headers, destPath); err != nil {
return "", "", err
}
data, err := os.ReadFile(destPath)
if err != nil {
return "", "", fmt.Errorf("读取下载内容失败: %w", err)
}
return string(data), sanitizeFileName(filename), nil
}
// resolveFileDomain probes both domains without guessing when neither probe
// succeeds. Explicit --space-id/--workspace routing bypasses this helper.
func resolveFileDomain(ctx context.Context, nodeID string) (string, error) {
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, driveErr := callMCPToolReturnTextOnServer(probeCtx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if driveErr == nil {
return "drive", nil
}
_, docErr := callMCPToolReturnTextOnServer(probeCtx, "doc", "get_document_info", map[string]any{"nodeId": nodeID})
if docErr == nil {
return "doc", nil
}
if isTimeoutCLIError(driveErr) || isTimeoutCLIError(docErr) {
return "", fmt.Errorf("路由文件所在域超时,请重试或通过 --space-id(钉盘)/ --workspace(知识库)显式指定")
}
if isPermissionCLIError(driveErr) || isPermissionCLIError(docErr) {
return "", fmt.Errorf("无权限访问该文件,请确认权限或通过 --space-id/--workspace 显式指定所在域")
}
return "", fmt.Errorf("文件 %s 在钉盘和知识库中均未找到,请确认 node ID 或显式指定 --space-id/--workspace", nodeID)
}
func fetchRemoteFileName(ctx context.Context, nodeID string, useDocServer bool) (string, error) {
serverID, toolName, args := "drive", "get_file_info", map[string]any{"fileId": nodeID}
if useDocServer {
serverID, toolName, args = "doc", "get_document_info", map[string]any{"nodeId": nodeID}
}
text, err := callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
if err != nil {
return "", err
}
return parseRemoteFileName(text), nil
}
func parseRemoteFileName(text string) string {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return ""
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if name, ok := data["fileName"].(string); ok && name != "" {
return sanitizeFileName(name)
}
name, _ := data["name"].(string)
ext, _ := data["extension"].(string)
if name == "" {
return ""
}
if ext == "" || strings.HasSuffix(strings.ToLower(name), "."+strings.ToLower(ext)) {
return sanitizeFileName(name)
}
return sanitizeFileName(name + "." + ext)
}
func isTimeoutCLIError(err error) bool {
if err == nil {
return false
}
var cliErr *CLIError
if errors.As(err, &cliErr) {
return cliErr.Code == CodeNetworkTimeout || cliErr.Code == CodeLockTimeout
}
return isTimeoutError(err.Error())
}
func isPermissionCLIError(err error) bool {
if err == nil {
return false
}
var cliErr *CLIError
if errors.As(err, &cliErr) {
return cliErr.Code == CodeAuthPermission
}
var patErr *PATError
return errors.As(err, &patErr)
}
// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。
// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、
// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token,
// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。
func parseDriveDownloadInfo(text string) (string, map[string]string, error) {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
headers := make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
dlURL, _ := data["downloadUrl"].(string)
if dlURL == "" {
dlURL, _ = data["resourceUrl"].(string)
}
if dlURL == "" {
if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 {
if first, ok := arr[0].(map[string]any); ok {
dlURL, _ = first["url"].(string)
if h, ok := first["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
}
}
if dlURL == "" {
return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)")
}
return dlURL, headers, nil
}