package helpers import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/spf13/cobra" ) // ────────────────────────────────────────────────────────── // 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 } func newDriveCommand() *cobra.Command { driveCmd := &cobra.Command{ Use: "drive", Short: "钉盘文件管理", Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`, RunE: groupRunE, } driveListCmd := &cobra.Command{ Use: "list", Short: "获取文件/文件夹列表(统一入口)", Long: `列出文件和文件夹。根据参数自动路由到钉盘或文档空间。 路由规则: 默认(无 --workspace) → 列出钉盘「我的文件」 --space-id <纯数字> → 列出钉盘指定空间 --workspace <加密string/URL> → 列出文档空间/知识库文件(等同于原 list-docs) --folder → 列出指定文件夹下的子节点`, Example: ` dws drive list --limit 20 dws drive list --folder --order-by name --order asc dws drive list --workspace dws drive list --workspace --folder `, RunE: func(cmd *cobra.Command, args []string) error { pattern, _ := cmd.Flags().GetString("pattern") depth, _ := cmd.Flags().GetInt("depth") // --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 // 知识库无对应参数,静默忽略。 if depth > 1 { 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) } 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) } if depth > 1 { 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) } // 默认路由:钉盘文件列表 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 } return callMCPTool("list_files", argsMap) }, } driveInfoCmd := &cobra.Command{ Use: "info", Short: "获取文件元数据信息", Long: `获取钉盘文件/文件夹的元数据信息。 如果目标文件属于钉钉文档(在线文档/表格/脑图等),会自动跟进调用 钉钉文档接口获取更准确的文档信息(如真实文档名称),并合并输出。`, Example: ` dws drive info --node # 查询 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) }, } driveDownloadCmd := &cobra.Command{ Use: "download", Short: "下载钉盘文件到本地", Long: `下载钉盘中的文件到本地(两步下载流程)。 流程: 1. 获取下载 URL 和签名请求头 (download_file) 2. HTTP GET 下载文件二进制内容到本地 --output 指定本地保存路径,可以是文件路径或目录。 如果指定目录,文件名从下载 URL 中自动推断。`, Example: ` dws drive download --node --output ./report.pdf dws drive download --node --output ~/downloads/`, 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 } if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "下载钉盘文件") deps.Out.PrintKeyValue("文件ID", fileID) deps.Out.PrintKeyValue("输出", outputPath) return nil } ctx := context.Background() // Step 1: 获取下载 URL 和签名请求头 deps.Out.PrintInfo("[1/2] 获取下载链接...") text, err := callMCPToolReturnText(ctx, "download_file", argsMap) if err != nil { return err } resourceURL, dlHeaders, err := parseDownloadInfo(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: HTTP GET 下载文件 deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { return err } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, } 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 --versions 获取。`, Example: ` dws drive download-version --node --version 3 --output ./report_v3.pdf dws drive download-version --node --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") } if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "下载文件历史版本") deps.Out.PrintKeyValue("节点ID", fileID) deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum)) deps.Out.PrintKeyValue("输出", outputPath) return nil } ctx := context.Background() deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{ "nodeId": fileID, "version": versionNum, }) if err != nil { return err } resourceURL, dlHeaders, err := parseDownloadInfo(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) } deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { return err } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, } driveMkdirCmd := &cobra.Command{ Use: "mkdir", Short: "创建文件夹", Example: ` dws drive mkdir --name "项目资料" dws drive mkdir --name "子目录" --folder `, 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) }, } 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 `, 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) }, } driveCommitCmd := &cobra.Command{ Use: "commit", Short: "提交文件上传", Example: ` dws drive commit --file-name "报告.pdf" --file-size 102400 --upload-id `, 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) }, } 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().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)") 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", "", "本地保存路径 (文件路径或目录,必填)") driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)") driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)") driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)") for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} { driveDownloadVersionCmd.Flags().String(alias, "", "") _ = driveDownloadVersionCmd.Flags().MarkHidden(alias) } 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 → 上传到文档空间/知识库 流程: 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 dws drive upload --file ./doc.pdf --workspace dws drive upload --file ./data.xlsx --workspace --convert`, RunE: runDriveUpload, } 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) }, } 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 。 结果中 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 `, 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 }, } 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 --yes # 查询 fileId: dws drive list dws drive delete --node # 交互式确认后删除`, RunE: func(cmd *cobra.Command, args []string) error { fileID := flagOrFallback(cmd, "node", "file-id") if fileID == "" { return fmt.Errorf("flag --node is required") } if !confirmDelete("钉盘节点", fileID) { return nil } // 同 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, }) }, } 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) }, } 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) }, } 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, }) }, } 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 dws drive stats --node https://alidocs.dingtalk.com/i/nodes/`, 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}) }, } driveStatsCmd.Flags().String("node", "", "节点 ID 或文档 URL (必填)") driveShortcutCmd := &cobra.Command{ Use: "shortcut", Short: "为节点创建快捷方式", Long: `为指定源节点创建快捷方式,并放置到目标文件夹或知识库。 通过 --node 指定源节点。--folder 和 --workspace 均为可选;都不传时由服务端选择默认位置。 若同时指定,--folder 是目标文件夹,--workspace 用于指定其所属知识库。`, Example: ` dws drive shortcut --node dws drive shortcut --node --folder dws drive shortcut --node --workspace `, 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) }, } driveShortcutCmd.Flags().String("node", "", "源节点 ID 或文档 URL (必填)") driveShortcutCmd.Flags().String("folder", "", "目标文件夹 nodeId (可选)") driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)") // ── drive permission (文档节点权限管理) ── drivePermissionCmd := &cobra.Command{ Use: "permission", Aliases: []string{"perm"}, Short: "文档节点权限管理", Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。 注意: 仅适用于文档空间节点,不适用于钉盘文件。`, RunE: groupRunE, } drivePermAddCmd := &cobra.Command{ Use: "add", Short: "添加协作者", Long: `为文档空间节点添加协作成员并授予指定角色。 支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`, Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`, 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")), "userIds": userIds, } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v } return callMCPToolOnServer("doc", "add_permission", toolArgs) }, } 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 (选填)") drivePermUpdateCmd := &cobra.Command{ Use: "update", Short: "更新协作者权限", Long: `更新文档空间节点已有协作者的权限角色。 支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`, Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`, 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")), "userIds": userIds, } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v } return callMCPToolOnServer("doc", "update_permission", toolArgs) }, } 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 (选填)") drivePermListCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "查询协作者列表", Long: `查询文档空间节点的协作者列表。`, Example: ` dws drive permission list --node DOC_ID dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`, 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} limit := 0 if cmd.Flags().Changed("limit") { limit, _ = cmd.Flags().GetInt("limit") } if limit > 0 { toolArgs["maxResults"] = limit } 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) }, } drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200") drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER") drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)") drivePermRemoveCmd := &cobra.Command{ Use: "remove", Aliases: []string{"rm"}, Short: "移除协作者权限", Long: `从文档空间节点移除协作成员的权限。`, Example: ` dws drive permission remove --node DOC_ID --users uid1 dws drive permission remove --node DOC_ID --users uid1,uid2`, 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 } userIds, err := collectUserIDs(cmd) if err != nil { return err } toolArgs := map[string]any{ "nodeId": nodeID, "userIds": userIds, } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v } return callMCPToolOnServer("doc", "remove_permission", toolArgs) }, } drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)") drivePermRemoveCmd.Flags().String("user", "", "") _ = drivePermRemoveCmd.Flags().MarkHidden("user") drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)") // permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致) for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, 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 } if !yesMode && !confirmDangerousAction(cmd, "转交所有者", target) { return nil } 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) }, } 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}) }, } 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 } if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "发起权限申请", fmt.Sprintf("节点 %s(将真实通知审批人 %s)", nodeID, strings.Join(userIds, ","))) { return nil } return callMCPToolOnServer("drive", "apply_permission", toolArgs) }, } 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, 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 := &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) }, } 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, }) }, } recycleRestoreCmd.Flags().String("id", "", "回收项 ID (必填,从 recycle list 获取)") recycleCmd.AddCommand(recycleListCmd, recycleRestoreCmd) // ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)── // folder create → dws wiki node create --type folder driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE} driveFolderCreateCmd := &cobra.Command{ Use: "create", Short: "创建文件夹(deprecated)", Long: `已废弃。请使用 'dws wiki node create --workspace --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 --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 := &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 --yes dws drive publish set --node --permission READER --yes dws drive publish set --node # 交互式确认`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id") if err != nil { return err } if !confirmDangerousAction(cmd, "enable internet publishing", nodeID) { return nil } toolArgs := map[string]any{ "fileId": nodeID, "published": true, } if v := mustGetFlag(cmd, "permission"); v != "" { toolArgs["publishPermission"] = v } return callMCPTool("set_file_publish", toolArgs) }, } 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 --yes dws drive publish unset --node # 交互式确认`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "file-id") if err != nil { return err } if !confirmDangerousAction(cmd, "disable internet publishing", nodeID) { return nil } return callMCPTool("set_file_publish", map[string]any{ "fileId": nodeID, "published": false, }) }, } drivePublishUnsetCmd.Flags().String("node", "", "目标文件 ID (dentryUuid) 或 URL (必填)") drivePublishGetCmd := &cobra.Command{ Use: "get", Aliases: []string{"status"}, Short: "查询文件公开发布状态", Long: `查询文件当前是否处于互联网公开发布状态,以及公开发布的权限设置。`, Example: ` dws drive publish get --node `, 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, }) }, } 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, 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) }, } 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 := &cobra.Command{ Use: "star", Short: "文档收藏管理", RunE: groupRunE, } driveStarAddCmd := &cobra.Command{ Use: "add", Short: "收藏文档", Example: ` dws drive star add --node `, 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}) }, } driveStarAddCmd.Flags().String("node", "", "文档 ID 或 URL (必填)") driveStarRemoveCmd := &cobra.Command{ Use: "remove", Aliases: []string{"rm"}, Short: "取消收藏文档", Example: ` dws drive star remove --node `, 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}) }, } 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) }, } 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 ", 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}) }, } driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)") // ── 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 --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") } if !commandDryRun(cmd) && !confirmDangerousAction(cmd, "回滚文件版本", fmt.Sprintf("节点 %s 回滚到版本 %d", nodeID, versionNum)) { return nil } return callMCPToolOnServer("drive", "revert_file_version", map[string]any{ "nodeId": nodeID, "version": versionNum, }) }, } driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)") driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)") driveCmd.AddCommand( driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd, driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd, driveSearchCmd, driveRecentCmd, // 文档空间代理命令(Phase 1) driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd, drivePermissionCmd, drivePublishCmd, recycleCmd, 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 } resourceURL, uploadID, headers, err := parseDriveUploadInfo(text) if err != nil { return err } if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); 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) }