Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
var DriveAddComment = common.Shortcut{ Service: "drive", Command: "+add-comment", Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only", Risk: "write", Scopes: []string{ "drive:drive.metadata:readonly", "docx:document:readonly", "docs:document.comment:create", "docs:document.comment:write_only", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true}, {Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}}, {Name: "content", Desc: "reply_elements JSON string", Required: true}, {Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"}, {Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"}, {Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type")) if err != nil { return err } if _, err := parseCommentReplyElements(runtime.Str("content")); err != nil { return err } if docRef.Kind == "sheet" { blockID := strings.TrimSpace(runtime.Str("block-id")) if blockID == "" { return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)") } if _, err := parseSheetCellRef(blockID); err != nil { return err } if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format") } return nil } if docRef.Kind == "slides" { if _, _, err := parseSlidesBlockRef(runtime.Str("block-id")); err != nil { return err } if runtime.Bool("full-comment") { return output.ErrValidation("--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>") } if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { return output.ErrValidation("--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>") } return nil } selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) if strings.TrimSpace(selection) != "" && blockID != "" { return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive") } if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") { return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id") } mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) if docRef.Kind == "file" { return validateFileCommentMode(mode, "") } if mode == commentModeLocal && docRef.Kind == "doc" { return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type")) replyElements, _ := parseCommentReplyElements(runtime.Str("content")) selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) resolvedKind := docRef.Kind resolvedToken := docRef.Token isWiki := false if docRef.Kind == "wiki" { isWiki = true target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), mode) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } resolvedKind = target.FileType resolvedToken = target.FileToken } if resolvedKind == "sheet" { anchor, _ := parseSheetCellRef(blockID) if anchor == nil { anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0} } commentBody := buildCommentCreateV2Request("sheet", "", "", replyElements, anchor) desc := "1-step request: create sheet comment" if isWiki { desc = "2-step orchestration: resolve wiki -> create sheet comment" } return common.NewDryRunAPI(). Desc(desc). POST("/open-apis/drive/v1/files/:file_token/new_comments"). Body(commentBody). Set("file_token", resolvedToken) } if resolvedKind == "slides" { slideAnchorBlockID, slideBlockType, err := parseSlidesBlockRef(blockID) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } commentBody := buildCommentCreateV2Request("slides", slideAnchorBlockID, slideBlockType, replyElements, nil) desc := "1-step request: create slide block comment" if isWiki { desc = "2-step orchestration: resolve wiki -> create slide block comment" } return common.NewDryRunAPI(). Desc(desc). POST("/open-apis/drive/v1/files/:file_token/new_comments"). Body(commentBody). Set("file_token", resolvedToken) } if resolvedKind == "file" { commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil) desc := "2-step orchestration: verify supported file metadata -> create file comment" verifyStep := "[1]" createStep := "[2]" if isWiki { desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment" verifyStep = "[2]" createStep = "[3]" } return common.NewDryRunAPI(). Desc(desc). POST("/open-apis/drive/v1/metas/batch_query"). Desc(verifyStep+" Read file metadata and verify the title extension is supported"). Body(map[string]interface{}{ "request_docs": []map[string]interface{}{ { "doc_token": resolvedToken, "doc_type": "file", }, }, }). POST("/open-apis/drive/v1/files/:file_token/new_comments"). Desc(createStep+" Create file full comment"). Body(commentBody). Set("file_token", resolvedToken) } createPath := "/open-apis/drive/v1/files/:file_token/new_comments" commentBody := buildCommentCreateV2Request(resolvedKind, "", "", replyElements, nil) if mode == commentModeLocal { commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), "", replyElements, nil) } mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand) dry := common.NewDryRunAPI() switch { case mode == commentModeFull && isWiki: dry.Desc("2-step orchestration: resolve wiki -> create full comment") case mode == commentModeFull: dry.Desc("1-step request: create full comment") case isWiki && strings.TrimSpace(selection) != "": dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment") case isWiki: dry.Desc("2-step orchestration: resolve wiki -> create local comment") case strings.TrimSpace(selection) != "": dry.Desc("2-step orchestration: locate block -> create local comment") default: dry.Desc("1-step request: create local comment with explicit block ID") } if mode == commentModeLocal && strings.TrimSpace(selection) != "" { step := "[1]" if isWiki { step = "[2]" } docID := resolvedToken if isWiki && resolvedToken == docRef.Token { docID = "<resolved_docx_token>" } mcpArgs := map[string]interface{}{ "doc_id": docID, "limit": defaultLocateDocLimit, "selection_with_ellipsis": selection, } dry.POST(mcpEndpoint). Desc(step+" MCP tool: locate-doc"). Body(map[string]interface{}{ "method": "tools/call", "params": map[string]interface{}{ "name": "locate-doc", "arguments": mcpArgs, }, }). Set("mcp_tool", "locate-doc"). Set("args", mcpArgs) } step := "[1]" createDesc := "Create full comment" if mode == commentModeLocal { createDesc = "Create local comment" step = "[2]" if isWiki && strings.TrimSpace(selection) != "" { step = "[3]" } else if isWiki || strings.TrimSpace(selection) != "" { step = "[2]" } else { step = "[1]" } } else if isWiki { step = "[2]" } return dry.POST(createPath). Desc(step+" "+createDesc). Body(commentBody). Set("file_token", resolvedToken) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type")) if docRef.Kind == "sheet" { return executeSheetComment(runtime, docRef) } if docRef.Kind == "slides" { return executeSlidesComment(runtime, docRef) } selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), mode) if err != nil { return err } if target.FileType == "sheet" { return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken}) } if target.FileType == "slides" { return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken}) } if target.FileType == "file" { return executeFileComment(runtime, target) } replyElements, err := parseCommentReplyElements(runtime.Str("content")) if err != nil { return err } var locateResult locateDocResult selectedMatch := 0 if mode == commentModeLocal && blockID == "" { _, locateResult, err = locateDocumentSelection(runtime, target, selection, defaultLocateDocLimit) if err != nil { return err } match, idx, err := selectLocateMatch(locateResult) if err != nil { return err } blockID = match.AnchorBlockID if strings.TrimSpace(blockID) == "" { return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") } selectedMatch = idx fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID) } else if mode == commentModeLocal { fmt.Fprintf(runtime.IO().ErrOut, "Using explicit block ID: %s\n", blockID) } requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken)) requestBody := buildCommentCreateV2Request(target.FileType, "", "", replyElements, nil) if mode == commentModeLocal { requestBody = buildCommentCreateV2Request(target.FileType, blockID, "", replyElements, nil) } if mode == commentModeLocal { fmt.Fprintf(runtime.IO().ErrOut, "Creating local comment in %s\n", common.MaskToken(target.FileToken)) } else { fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken)) } data, err := runtime.CallAPI( "POST", requestPath, nil, requestBody, ) if err != nil { return err } out := map[string]interface{}{ "comment_id": data["comment_id"], "doc_id": target.DocID, "file_token": target.FileToken, "file_type": target.FileType, "resolved_by": target.ResolvedBy, "comment_mode": string(mode), } if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil { out["created_at"] = createdAt } if target.WikiToken != "" { out["wiki_token"] = target.WikiToken } if mode == commentModeLocal { out["anchor_block_id"] = blockID out["selection_source"] = "block_id" if strings.TrimSpace(selection) != "" { out["selection_source"] = "locate-doc" out["selection_with_ellipsis"] = selection out["match_count"] = locateResult.MatchCount out["match_index"] = selectedMatch } } else if isWhole, ok := data["is_whole"]; ok { out["is_whole"] = isWhole } runtime.Out(out, nil) return nil }, }
var DriveApplyPermission = common.Shortcut{ Service: "drive", Command: "+apply-permission", Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides", Risk: "write", Scopes: []string{"docs:permission.member:apply"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true}, {Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes}, {Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}}, {Name: "remark", Desc: "optional note shown on the request card sent to the owner"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { _, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } body := buildPermApplyBody(runtime) return common.NewDryRunAPI(). Desc("Apply to document owner for access"). POST("/open-apis/drive/v1/permissions/:token/members/apply"). Params(map[string]interface{}{"type": docType}). Body(body). Set("token", token) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) if err != nil { return err } body := buildPermApplyBody(runtime) fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n", runtime.Str("perm"), docType, common.MaskToken(token)) data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)), map[string]interface{}{"type": docType}, body, ) if err != nil { return err } runtime.Out(data, nil) return nil }, }
DriveApplyPermission applies to the document owner for view or edit access on behalf of the invoking user. Matches the open-apis endpoint /open-apis/drive/v1/permissions/:token/members/apply.
The backend accepts only user_access_token for this endpoint, so the shortcut declares AuthTypes: ["user"] — bot identity is rejected up-front.
var DriveCreateFolder = common.Shortcut{ Service: "drive", Command: "+create-folder", Description: "Create a folder in Drive", Risk: "write", Scopes: []string{"space:folder:create"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "name", Desc: "folder name", Required: true}, {Name: "folder-token", Desc: "parent folder token (default: root folder)"}, }, Tips: []string{ "Omit --folder-token to create the folder in the caller's root folder.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime)) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := newDriveCreateFolderSpec(runtime) dry := common.NewDryRunAPI(). Desc("Create a folder in Drive"). POST("/open-apis/drive/v1/files/create_folder"). Desc("[1] Create folder"). Body(spec.RequestBody()) if runtime.IsBot() { dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.") } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := newDriveCreateFolderSpec(runtime) target := "root folder" if spec.FolderToken != "" { target = "folder " + common.MaskToken(spec.FolderToken) } fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target) data, err := runtime.CallAPI( "POST", "/open-apis/drive/v1/files/create_folder", nil, spec.RequestBody(), ) if err != nil { return err } folderToken := common.GetString(data, "token") if folderToken == "" { return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)") } out := map[string]interface{}{ "created": true, "name": spec.Name, "folder_token": folderToken, "parent_folder_token": spec.FolderToken, } if url := strings.TrimSpace(common.GetString(data, "url")); url != "" { out["url"] = url } else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" { out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil { out["permission_grant"] = grant } runtime.Out(out, nil) return nil }, }
DriveCreateFolder creates a new Drive folder under the specified parent folder, or under the caller's root folder when --folder-token is omitted.
var DriveCreateShortcut = common.Shortcut{ Service: "drive", Command: "+create-shortcut", Description: "Create a Drive shortcut in another folder", Risk: "write", Scopes: []string{"space:document:shortcut"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "source file token to reference", Required: true}, {Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true}, {Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime)) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := newDriveCreateShortcutSpec(runtime) return common.NewDryRunAPI(). Desc("Create a Drive shortcut"). POST("/open-apis/drive/v1/files/create_shortcut"). Desc("[1] Create shortcut"). Body(spec.RequestBody()) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := newDriveCreateShortcutSpec(runtime) fmt.Fprintf( runtime.IO().ErrOut, "Creating shortcut for %s %s in folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken), ) data, err := runtime.CallAPI( "POST", "/open-apis/drive/v1/files/create_shortcut", nil, spec.RequestBody(), ) if err != nil { return err } out := map[string]interface{}{ "created": true, "source_file_token": spec.FileToken, "source_type": spec.FileType, "folder_token": spec.FolderToken, } if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" { out["shortcut_token"] = shortcutToken } if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" { out["url"] = url } if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" { out["title"] = title } runtime.Out(out, nil) return nil }, }
DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
var DriveDelete = common.Shortcut{ Service: "drive", Command: "+delete", Description: "Delete a file or folder in Drive", Risk: "high-risk-write", Scopes: []string{"space:document:delete"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "file or folder token to delete", Required: true}, {Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveDeleteSpec(driveDeleteSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveDeleteSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), } dry := common.NewDryRunAPI(). Desc("Delete file or folder in Drive") dry.DELETE("/open-apis/drive/v1/files/:file_token"). Desc("[1] Delete file/folder"). Set("file_token", spec.FileToken). Params(map[string]interface{}{"type": spec.FileType}) if spec.FileType == "folder" { dry.GET("/open-apis/drive/v1/files/task_check"). Desc("[2] Poll async task status (for folder delete)"). Params(driveTaskCheckParams("<task_id>")) } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveDeleteSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), } fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken)) data, err := runtime.CallAPI( "DELETE", fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)), map[string]interface{}{"type": spec.FileType}, nil, ) if err != nil { return err } if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID) status, ready, err := pollDriveTaskCheck(runtime, taskID) if err != nil { return err } out := map[string]interface{}{ "task_id": taskID, "status": status.StatusLabel(), "file_token": spec.FileToken, "type": spec.FileType, "ready": ready, } if ready { out["deleted"] = true } if !ready { nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As())) fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand) out["timed_out"] = true out["next_command"] = nextCommand } runtime.Out(out, nil) return nil } runtime.Out(map[string]interface{}{ "deleted": true, "file_token": spec.FileToken, "type": spec.FileType, }, nil) return nil }, }
DriveDelete deletes a Drive file or folder and handles the async task polling required by folder deletes.
var DriveDownload = common.Shortcut{ Service: "drive", Command: "+download", Description: "Download a file from Drive to local", Risk: "read", Scopes: []string{"drive:file:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "file token", Required: true}, {Name: "output", Desc: "local save path"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { fileToken := runtime.Str("file-token") outputPath := runtime.Str("output") if outputPath == "" { outputPath = fileToken } return common.NewDryRunAPI(). GET("/open-apis/drive/v1/files/:file_token/download"). Set("file_token", fileToken).Set("output", outputPath) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { fileToken := runtime.Str("file-token") outputPath := runtime.Str("output") overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(fileToken, "--file-token"); err != nil { return output.ErrValidation("%s", err) } if outputPath == "" { outputPath = fileToken } if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { return output.ErrValidation("unsafe output path: %s", resolveErr) } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite { return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { return output.ErrNetwork("download failed: %s", err) } defer resp.Body.Close() result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body) if err != nil { return common.WrapSaveErrorByCategory(err, "io") } savedPath, _ := runtime.ResolveSavePath(outputPath) if savedPath == "" { savedPath = outputPath } runtime.Out(map[string]interface{}{ "saved_path": savedPath, "size_bytes": result.Size(), }, nil) return nil }, }
var DriveExport = common.Shortcut{ Service: "drive", Command: "+export", Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling", Risk: "read", Scopes: []string{ "docs:document.content:read", "docs:document:export", "docx:document:readonly", "drive:drive.metadata:readonly", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "token", Desc: "source document token", Required: true}, {Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}}, {Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}}, {Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"}, {Name: "file-name", Desc: "preferred output filename (optional)"}, {Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveExportSpec(driveExportSpec{ Token: runtime.Str("token"), DocType: runtime.Str("doc-type"), FileExtension: runtime.Str("file-extension"), SubID: runtime.Str("sub-id"), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveExportSpec{ Token: runtime.Str("token"), DocType: runtime.Str("doc-type"), FileExtension: runtime.Str("file-extension"), SubID: runtime.Str("sub-id"), } if spec.FileExtension == "markdown" { apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) dr := common.NewDryRunAPI(). Desc("2-step orchestration: fetch docx markdown -> write local file"). POST(apiPath). Body(map[string]interface{}{ "format": "markdown", }). Set("output_dir", runtime.Str("output-dir")) if name := strings.TrimSpace(runtime.Str("file-name")); name != "" { dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension)) } return dr } body := map[string]interface{}{ "token": spec.Token, "type": spec.DocType, "file_extension": spec.FileExtension, } if strings.TrimSpace(spec.SubID) != "" { body["sub_id"] = spec.SubID } dr := common.NewDryRunAPI(). Desc("3-step orchestration: create export task -> limited polling -> download file"). POST("/open-apis/drive/v1/export_tasks"). Body(body). Set("output_dir", runtime.Str("output-dir")) if name := strings.TrimSpace(runtime.Str("file-name")); name != "" { dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension)) } return dr }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveExportSpec{ Token: runtime.Str("token"), DocType: runtime.Str("doc-type"), FileExtension: runtime.Str("file-extension"), SubID: runtime.Str("sub-id"), } outputDir := runtime.Str("output-dir") preferredFileName := strings.TrimSpace(runtime.Str("file-name")) overwrite := runtime.Bool("overwrite") if spec.FileExtension == "markdown" { fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token)) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) data, err := runtime.DoAPIJSONWithLogID( "POST", apiPath, nil, map[string]interface{}{ "format": "markdown", }, ) if err != nil { return err } doc, ok := data["document"].(map[string]interface{}) if !ok { return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object") } content, ok := doc["content"].(string) if !ok { return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content") } fileName := preferredFileName if fileName == "" { title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType) if err != nil { fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err) title = spec.Token } fileName = title } fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension) savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite) if err != nil { return err } runtime.Out(map[string]interface{}{ "token": spec.Token, "doc_type": spec.DocType, "file_extension": spec.FileExtension, "file_name": filepath.Base(savedPath), "saved_path": savedPath, "size_bytes": len(content), }, nil) return nil } ticket, err := createDriveExportTask(runtime, spec) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket) var lastStatus driveExportStatus var lastPollErr error hasObservedStatus := false for attempt := 1; attempt <= driveExportPollAttempts; attempt++ { if attempt > 1 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(driveExportPollInterval): } } if err := ctx.Err(); err != nil { return err } status, err := getDriveExportStatus(runtime, spec.Token, ticket) if err != nil { lastPollErr = err fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err) continue } lastStatus = status hasObservedStatus = true if status.Ready() { fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken)) fileName := preferredFileName if fileName == "" { fileName = status.FileName } fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension) out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite) if err != nil { recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite) hint := fmt.Sprintf( "the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s", ticket, status.FileToken, recoveryCommand, ) var exitErr *output.ExitError if errors.As(err, &exitErr) && exitErr.Detail != nil { return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) } return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint) } out["ticket"] = ticket out["doc_type"] = spec.DocType out["file_extension"] = spec.FileExtension runtime.Out(out, nil) return nil } if status.Failed() { msg := strings.TrimSpace(status.JobErrorMsg) if msg == "" { msg = status.StatusLabel() } return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket) } fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel()) } nextCommand := driveExportTaskResultCommand(ticket, spec.Token) if !hasObservedStatus && lastPollErr != nil { hint := fmt.Sprintf( "the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s", ticket, nextCommand, ) var exitErr *output.ExitError if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil { if strings.TrimSpace(exitErr.Detail.Hint) != "" { hint = exitErr.Detail.Hint + "\n" + hint } return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) } return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint) } failed := false var jobStatus interface{} jobStatusLabel := "unknown" if hasObservedStatus { failed = lastStatus.Failed() jobStatus = lastStatus.JobStatus jobStatusLabel = lastStatus.StatusLabel() } result := map[string]interface{}{ "ticket": ticket, "token": spec.Token, "doc_type": spec.DocType, "file_extension": spec.FileExtension, "ready": false, "failed": failed, "job_status": jobStatus, "job_status_label": jobStatusLabel, "timed_out": true, "next_command": nextCommand, } if preferredFileName != "" { result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension) } runtime.Out(result, nil) fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand) return nil }, }
DriveExport exports Drive-native documents to local files and falls back to a follow-up command when the async export task does not finish in time.
var DriveExportDownload = common.Shortcut{ Service: "drive", Command: "+export-download", Description: "Download an exported file by file_token", Risk: "read", Scopes: []string{ "docs:document:export", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "exported file token", Required: true}, {Name: "file-name", Desc: "preferred output filename (optional)"}, {Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { return output.ErrValidation("%s", err) } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/drive/v1/export_tasks/file/:file_token/download"). Set("file_token", runtime.Str("file-token")). Set("output_dir", runtime.Str("output-dir")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { out, err := downloadDriveExportFile( ctx, runtime, runtime.Str("file-token"), runtime.Str("output-dir"), runtime.Str("file-name"), runtime.Bool("overwrite"), ) if err != nil { return err } runtime.Out(out, nil) return nil }, }
DriveExportDownload downloads an already-generated export artifact when the caller has a file token from a previous export task.
var DriveImport = common.Shortcut{ Service: "drive", Command: "+import", Description: "Import a local file to Drive as a cloud document (docx, sheet, bitable)", Risk: "write", Scopes: []string{ "docs:document.media:upload", "docs:document:import", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base; large files auto use multipart upload; .base is capped at 20MB)", Required: true}, {Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true}, {Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"}, {Name: "name", Desc: "imported file name (default: local file name without extension)"}, {Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveImportSpec(driveImportSpec{ FilePath: runtime.Str("file"), DocType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), TargetToken: runtime.Str("target-token"), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveImportSpec{ FilePath: runtime.Str("file"), DocType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), TargetToken: runtime.Str("target-token"), } fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } if valErr := validateDriveImportSpec(spec); valErr != nil { return common.NewDryRunAPI().Set("error", valErr.Error()) } dry := common.NewDryRunAPI() dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status") appendDriveImportUploadDryRun(dry, spec, fileSize) dry.POST("/open-apis/drive/v1/import_tasks"). Desc("[2] Create import task"). Body(spec.CreateTaskBody("<file_token>")) dry.GET("/open-apis/drive/v1/import_tasks/:ticket"). Desc("[3] Poll import task result"). Set("ticket", "<ticket>") if runtime.IsBot() { dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.") } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveImportSpec{ FilePath: runtime.Str("file"), DocType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), TargetToken: runtime.Str("target-token"), } if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil { return err } fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType) if uploadErr != nil { return uploadErr } fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType) ticket, err := createDriveImportTask(runtime, spec, fileToken) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket) status, ready, err := pollDriveImportTask(runtime, ticket) if err != nil { return err } resultType := status.DocType if resultType == "" { resultType = spec.DocType } out := map[string]interface{}{ "ticket": ticket, "type": resultType, "ready": ready, "job_status": status.JobStatus, "job_status_label": status.StatusLabel(), } if status.Token != "" { out["token"] = status.Token } if statusURL := strings.TrimSpace(status.URL); statusURL != "" { out["url"] = statusURL } else if status.Token != "" { if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" { out["url"] = u } } if status.JobErrorMsg != "" { out["job_error_msg"] = status.JobErrorMsg } if status.Extra != nil { out["extra"] = status.Extra } if !ready { nextCommand := driveImportTaskResultCommand(ticket) fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand) out["timed_out"] = true out["next_command"] = nextCommand } if ready { if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil { out["permission_grant"] = grant } } runtime.Out(out, nil) return nil }, }
DriveImport uploads a local file, creates an import task, and polls until the imported cloud document is ready or the local polling window expires.
var DriveInspect = common.Shortcut{ Service: "drive", Command: "+inspect", Description: "Inspect a Lark document URL to get its type, title, and canonical token (with wiki unwrapping)", Risk: "read", Scopes: []string{"drive:drive.metadata:readonly"}, ConditionalScopes: []string{"wiki:node:retrieve"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ { Name: "url", Desc: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)", Required: true, }, { Name: "type", Desc: "document type (required when --url is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"}, }, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { raw := strings.TrimSpace(runtime.Str("url")) if raw == "" { return output.ErrValidation("--url cannot be empty") } _, ok := common.ParseResourceURL(raw) if !ok { if strings.Contains(raw, "://") { return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw) } if strings.TrimSpace(runtime.Str("type")) == "" { return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)") } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { raw := strings.TrimSpace(runtime.Str("url")) ref, ok := common.ParseResourceURL(raw) if !ok { ref = common.ResourceRef{ Type: strings.TrimSpace(runtime.Str("type")), Token: raw, } } dry := common.NewDryRunAPI() if ref.Type == "wiki" { dry.Desc("2-step: inspect wiki node, then batch query metadata") dry.GET("/open-apis/wiki/v2/spaces/get_node"). Desc("[1] Inspect wiki node to get underlying document"). Params(map[string]interface{}{"token": ref.Token}) dry.POST("/open-apis/drive/v1/metas/batch_query"). Desc("[2] Batch query document metadata (title)"). Body(map[string]interface{}{ "request_docs": []map[string]interface{}{ {"doc_token": "<obj_token from step 1>", "doc_type": "<obj_type from step 1>"}, }, }) return dry } dry.Desc("1-step: batch query document metadata") dry.POST("/open-apis/drive/v1/metas/batch_query"). Body(map[string]interface{}{ "request_docs": []map[string]interface{}{ {"doc_token": ref.Token, "doc_type": ref.Type}, }, }) return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { raw := strings.TrimSpace(runtime.Str("url")) ref, ok := common.ParseResourceURL(raw) if !ok { ref = common.ResourceRef{ Type: strings.TrimSpace(runtime.Str("type")), Token: raw, } } inputURL := raw docType := ref.Type docToken := ref.Token var wikiNode map[string]interface{} if docType == "wiki" { fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken)) data, err := runtime.CallAPI( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docToken}, nil, ) if err != nil { return err } node := common.GetMap(data, "node") objType := common.GetString(node, "obj_type") objToken := common.GetString(node, "obj_token") spaceID := common.GetString(node, "space_id") nodeToken := common.GetString(node, "node_token") if objType == "" || objToken == "" { return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) } wikiNode = map[string]interface{}{ "space_id": spaceID, "node_token": nodeToken, "obj_token": objToken, "obj_type": objType, } docType = objType docToken = objToken fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", docType, common.MaskToken(docToken)) } title, err := common.FetchDriveMetaTitle(runtime, docToken, docType) if err != nil { return err } resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken) result := map[string]interface{}{ "input_url": inputURL, "type": docType, "title": title, "token": docToken, "url": resolvedURL, } if wikiNode != nil { result["wiki_node"] = wikiNode } runtime.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "Type: %s\n", docType) if title != "" { fmt.Fprintf(w, "Title: %s\n", title) } fmt.Fprintf(w, "Token: %s\n", docToken) if resolvedURL != "" { fmt.Fprintf(w, "URL: %s\n", resolvedURL) } if wikiNode != nil { fmt.Fprintf(w, "Wiki: space_id=%s, node_token=%s\n", wikiNode["space_id"], wikiNode["node_token"]) } }) return nil }, }
var DriveMove = common.Shortcut{ Service: "drive", Command: "+move", Description: "Move a file or folder to another location in Drive", Risk: "write", Scopes: []string{"space:document:move"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "file or folder token to move", Required: true}, {Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, slides)", Required: true}, {Name: "folder-token", Desc: "target folder token (default: root folder)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveMoveSpec(driveMoveSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveMoveSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), } dry := common.NewDryRunAPI(). Desc("Move file or folder in Drive") dry.POST("/open-apis/drive/v1/files/:file_token/move"). Desc("[1] Move file/folder"). Set("file_token", spec.FileToken). Body(spec.RequestBody()) if spec.FileType == "folder" { dry.GET("/open-apis/drive/v1/files/task_check"). Desc("[2] Poll async task status (for folder move)"). Params(driveTaskCheckParams("<task_id>")) } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveMoveSpec{ FileToken: runtime.Str("file-token"), FileType: strings.ToLower(runtime.Str("type")), FolderToken: runtime.Str("folder-token"), } if spec.FolderToken == "" { fmt.Fprintf(runtime.IO().ErrOut, "No target folder specified, getting root folder...\n") rootToken, err := getRootFolderToken(ctx, runtime) if err != nil { return err } if rootToken == "" { return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty") } spec.FolderToken = rootToken } fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken)) data, err := runtime.CallAPI( "POST", fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)), nil, spec.RequestBody(), ) if err != nil { return err } if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID) status, ready, err := pollDriveTaskCheck(runtime, taskID) if err != nil { return err } out := map[string]interface{}{ "task_id": taskID, "status": status.StatusLabel(), "file_token": spec.FileToken, "folder_token": spec.FolderToken, "ready": ready, } if !ready { nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As())) fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand) out["timed_out"] = true out["next_command"] = nextCommand } runtime.Out(out, nil) } else { runtime.Out(map[string]interface{}{ "file_token": spec.FileToken, "folder_token": spec.FolderToken, "type": spec.FileType, }, nil) } return nil }, }
DriveMove moves a Drive file or folder and handles the async task polling required by folder moves.
var DrivePull = common.Shortcut{ Service: "drive", Command: "+pull", Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)", Risk: "write", Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "source Drive folder token", Required: true}, {Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, drivePullIfExistsSkip}}, {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, {Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"}, {Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"}, }, Tips: []string{ "Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", "Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.", "For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.", "Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.", "--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { return common.FlagErrorf("--local-dir is required") } if folderToken == "" { return common.FlagErrorf("--folder-token is required") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { return output.ErrValidation("%s", err) } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { return output.ErrValidation("%s", err) } info, err := runtime.FileIO().Stat(localDir) if err != nil { return common.WrapInputStatError(err) } if !info.IsDir() { return output.ErrValidation("--local-dir is not a directory: %s", localDir) } if runtime.Bool("delete-local") && !runtime.Bool("yes") { return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)") } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive."). GET("/open-apis/drive/v1/files"). Set("folder_token", runtime.Str("folder-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) ifExists := strings.TrimSpace(runtime.Str("if-exists")) if ifExists == "" { ifExists = drivePullIfExistsOverwrite } duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) if duplicateRemote == "" { duplicateRemote = driveDuplicateRemoteFail } deleteLocal := runtime.Bool("delete-local") safeRoot, err := validate.SafeInputPath(localDir) if err != nil { return output.ErrValidation("--local-dir: %s", err) } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { return output.ErrValidation("could not resolve cwd: %s", err) } rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { return output.ErrValidation("--local-dir resolves outside cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { return duplicateRemotePathError(duplicates) } remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%s", err) } var downloaded, skipped, failed, deletedLocal int downloadFailed := 0 items := make([]drivePullItem, 0) downloadablePaths := make([]string, 0, len(remoteFiles)) for p := range remoteFiles { downloadablePaths = append(downloadablePaths, p) } sort.Strings(downloadablePaths) for _, rel := range downloadablePaths { targetFile := remoteFiles[rel] downloadToken := targetFile.DownloadToken itemFileToken := targetFile.ItemFileToken itemSourceID := targetFile.ItemSourceID target := filepath.Join(rootRelToCwd, rel) if info, statErr := runtime.FileIO().Stat(target); statErr == nil { if info.IsDir() { items = append(items, drivePullItem{ RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target), }) failed++ downloadFailed++ continue } if ifExists == drivePullIfExistsSkip || drivePullShouldSkipSmart(target, targetFile, ifExists, runtime) { items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"}) skipped++ continue } } if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil { items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()}) failed++ downloadFailed++ continue } items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"}) downloaded++ } if deleteLocal && downloadFailed == 0 { localAbsPaths, err := drivePullWalkLocal(safeRoot) if err != nil { return err } for _, absPath := range localAbsPaths { rel, relErr := filepath.Rel(safeRoot, absPath) if relErr != nil { items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()}) failed++ continue } rel = filepath.ToSlash(rel) if _, ok := remotePaths[rel]; ok { continue } if err := os.Remove(absPath); err != nil { items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()}) failed++ continue } items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"}) deletedLocal++ } } payload := map[string]interface{}{ "summary": map[string]interface{}{ "downloaded": downloaded, "skipped": skipped, "failed": failed, "deleted_local": deletedLocal, }, "items": items, } if failed > 0 { msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed) if deleteLocal && downloadFailed > 0 { msg += " (--delete-local was skipped because the download pass had failures)" } return &output.ExitError{ Code: output.ExitAPI, Detail: &output.ErrDetail{ Type: "partial_failure", Message: msg, Detail: payload, }, } } runtime.Out(payload, nil) return nil }, }
DrivePull performs a one-way file-level mirror from a Drive folder onto a local directory: recursively lists --folder-token, downloads each type=file entry under --local-dir, and optionally deletes local files absent from Drive (--delete-local --yes).
Only Drive entries with type=file participate; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped because there is no equivalent local binary to write back. Directories are reproduced when remote folders contain downloadable files, but local directories that become orphaned after a remote folder is removed are NOT pruned — --delete-local only unlinks regular files.
var DrivePush = common.Shortcut{ Service: "drive", Command: "+push", Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)", Risk: "write", Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "target Drive folder token", Required: true}, {Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}}, {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, {Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"}, {Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"}, }, Tips: []string{ "This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.", "Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.", "For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.", "Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.", "Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.", "--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.", "--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.", "Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { return common.FlagErrorf("--local-dir is required") } if folderToken == "" { return common.FlagErrorf("--folder-token is required") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { return output.ErrValidation("%s", err) } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { return output.ErrValidation("%s", err) } info, err := runtime.FileIO().Stat(localDir) if err != nil { return common.WrapInputStatError(err) } if !info.IsDir() { return output.ErrValidation("--local-dir is not a directory: %s", localDir) } if runtime.Bool("delete-remote") && !runtime.Bool("yes") { return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)") } if runtime.Bool("delete-remote") && runtime.Bool("yes") { if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil { return err } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally."). GET("/open-apis/drive/v1/files"). Set("folder_token", runtime.Str("folder-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) ifExists := strings.TrimSpace(runtime.Str("if-exists")) if ifExists == "" { ifExists = drivePushIfExistsSkip } duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) if duplicateRemote == "" { duplicateRemote = driveDuplicateRemoteFail } deleteRemote := runtime.Bool("delete-remote") safeRoot, err := validate.SafeInputPath(localDir) if err != nil { return output.ErrValidation("--local-dir: %s", err) } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { return output.ErrValidation("could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { return duplicateRemotePathError(duplicates) } remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%s", err) } var uploaded, skipped, failed, deletedRemote int items := make([]drivePushItem, 0) uploadFailed := false folderCache := map[string]string{"": folderToken} for relDir, entry := range remoteFolders { folderCache[relDir] = entry.FileToken } for _, relDir := range localDirs { if _, alreadyRemote := folderCache[relDir]; alreadyRemote { continue } if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()}) failed++ uploadFailed = true continue } items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"}) } localPaths := make([]string, 0, len(localFiles)) for p := range localFiles { localPaths = append(localPaths, p) } sort.Strings(localPaths) for _, rel := range localPaths { localFile := localFiles[rel] if entry, ok := remoteFiles[rel]; ok { if drivePushShouldSkipExisting(localFile, entry, ifExists) { items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size}) skipped++ continue } parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache) if parentErr != nil { items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()}) failed++ uploadFailed = true continue } token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken) if upErr != nil { failedToken := token if failedToken == "" { failedToken = entry.FileToken } items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) failed++ uploadFailed = true continue } items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size}) uploaded++ continue } parentRel := drivePushParentRel(rel) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) if ensureErr != nil { items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()}) failed++ uploadFailed = true continue } token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) if upErr != nil { items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) failed++ uploadFailed = true continue } items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size}) uploaded++ } if deleteRemote && uploadFailed { fmt.Fprintf(runtime.IO().ErrOut, "Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n", failed) } if deleteRemote && !uploadFailed { remoteRelPaths := make([]string, 0, len(remoteFileGroups)) for p := range remoteFileGroups { remoteRelPaths = append(remoteRelPaths, p) } sort.Strings(remoteRelPaths) for _, rel := range remoteRelPaths { keepToken := "" if _, ok := localFiles[rel]; ok { if chosen, ok := remoteFiles[rel]; ok { keepToken = chosen.FileToken } } for _, entry := range remoteFileGroups[rel] { if entry.FileToken == keepToken { continue } if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()}) failed++ continue } items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"}) deletedRemote++ } } } runtime.Out(map[string]interface{}{ "summary": map[string]interface{}{ "uploaded": uploaded, "skipped": skipped, "failed": failed, "deleted_remote": deletedRemote, }, "items": items, }, nil) if failed > 0 { return output.ErrBare(output.ExitAPI) } return nil }, }
DrivePush is a one-way, file-level mirror from a local directory onto a Drive folder: walks --local-dir, recursively lists --folder-token, and for each rel_path uploads (or overwrites) the corresponding Drive file. With --delete-remote --yes, any type=file entry on Drive that has no local counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts and folders are never deleted, so this is "file-level" mirror — the command does not attempt to remove remote-only directories or close gaps in directory structure that exists on Drive but not locally.
Only Drive entries with type=file participate in upload/overwrite/delete; online documents have no equivalent local binary. Sub-folders are created on Drive on demand via /open-apis/drive/v1/files/create_folder so the remote tree mirrors the local tree.
The overwrite path passes the existing file_token as a form field on /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite contract in shortcuts/markdown. The Drive backend exposing that field is being rolled out; until rollout completes, --if-exists defaults to "skip" so the safe path (do not touch existing remote files) is the default and callers must opt into "overwrite" explicitly.
var DriveSearch = common.Shortcut{ Service: "drive", Command: "+search", Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)", Risk: "read", Scopes: []string{"search:docs:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword (may be empty to browse by filter only)"}, {Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"}, {Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"}, {Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"}, {Name: "edited-until", Desc: "end of [my edited] time window"}, {Name: "commented-since", Desc: "start of [my commented] time window"}, {Name: "commented-until", Desc: "end of [my commented] time window"}, {Name: "opened-since", Desc: "start of [my opened] time window"}, {Name: "opened-until", Desc: "end of [my opened] time window"}, {Name: "created-since", Desc: "start of [document created] time window"}, {Name: "created-until", Desc: "end of [document created] time window"}, {Name: "doc-types", Desc: "comma-separated types: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut"}, {Name: "folder-tokens", Desc: "comma-separated folder tokens (doc-only; mutually exclusive with --space-ids)"}, {Name: "space-ids", Desc: "comma-separated wiki space IDs (wiki-only; mutually exclusive with --folder-tokens)"}, {Name: "chat-ids", Desc: "comma-separated chat IDs"}, {Name: "sharer-ids", Desc: "comma-separated sharer open_ids"}, {Name: "only-title", Type: "bool", Desc: "match titles only"}, {Name: "only-comment", Type: "bool", Desc: "search comments only"}, {Name: "sort", Desc: "sort type", Enum: driveSearchSortValues}, {Name: "page-token", Desc: "pagination token from a previous response"}, {Name: "page-size", Default: "15", Desc: "page size (1-20, default 15)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveSearchIDs(readDriveSearchSpec(runtime)) }, Tips: []string{ "Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.", "my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.", "Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.", "--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.", }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := readDriveSearchSpec(runtime) reqBody, notices, err := buildDriveSearchRequest(spec, runtime.UserOpenId(), time.Now()) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } for _, n := range notices { fmt.Fprintln(runtime.IO().ErrOut, n) } return common.NewDryRunAPI(). POST("/open-apis/search/v2/doc_wiki/search"). Body(reqBody) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := readDriveSearchSpec(runtime) reqBody, notices, err := buildDriveSearchRequest(spec, runtime.UserOpenId(), time.Now()) if err != nil { return err } for _, n := range notices { fmt.Fprintln(runtime.IO().ErrOut, n) } data, err := callDriveSearchAPI(runtime, reqBody) if err != nil { return err } items, _ := data["res_units"].([]interface{}) normalizedItems := addDriveSearchIsoTimeFields(items) resultData := map[string]interface{}{ "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], "results": normalizedItems, } runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) { renderDriveSearchTable(w, data, normalizedItems) }) return nil }, }
DriveSearch searches docs/wikis via the v2 doc_wiki/search API using flat flags instead of a nested JSON filter, which is friendlier for AI agents and `--help` readers.
var DriveStatus = common.Shortcut{ Service: "drive", Command: "+status", Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time", Risk: "read", Scopes: []string{"drive:drive.metadata:readonly"}, ConditionalScopes: []string{"drive:file:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "Drive folder token", Required: true}, {Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"}, }, Tips: []string{ "Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", "Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.", "Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { return common.FlagErrorf("--local-dir is required") } if folderToken == "" { return common.FlagErrorf("--folder-token is required") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { return output.ErrValidation("%s", err) } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { return output.ErrValidation("%s", err) } info, err := runtime.FileIO().Stat(localDir) if err != nil { return common.WrapInputStatError(err) } if !info.IsDir() { return output.ErrValidation("--local-dir is not a directory: %s", localDir) } if !runtime.Bool("quick") { if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { return err } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256." if runtime.Bool("quick") { desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes." } return common.NewDryRunAPI(). Desc(desc). GET("/open-apis/drive/v1/files"). Set("folder_token", runtime.Str("folder-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) detection := driveStatusDetectionExact if runtime.Bool("quick") { detection = driveStatusDetectionQuick } safeRoot, err := validate.SafeInputPath(localDir) if err != nil { return output.ErrValidation("--local-dir: %s", err) } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { return output.ErrValidation("could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 { return duplicateRemotePathError(duplicates) } remoteFiles := make(map[string]driveStatusRemoteFile, len(entries)) for _, entry := range entries { if entry.Type == driveTypeFile { remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime} } } paths := mergeStatusPaths(localFiles, remoteFiles) var newLocal, newRemote, modified, unchanged []driveStatusEntry for _, relPath := range paths { localFile, hasLocal := localFiles[relPath] remoteFile, hasRemote := remoteFiles[relPath] switch { case hasLocal && !hasRemote: newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) case !hasLocal && hasRemote: newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}) default: entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken} if detection == driveStatusDetectionQuick { if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) { unchanged = append(unchanged, entry) } else { modified = append(modified, entry) } continue } localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd) if err != nil { return err } remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken) if err != nil { return err } if localHash == remoteHash { unchanged = append(unchanged, entry) } else { modified = append(modified, entry) } } } runtime.Out(map[string]interface{}{ "detection": detection, "new_local": emptyIfNil(newLocal), "new_remote": emptyIfNil(newRemote), "modified": emptyIfNil(modified), "unchanged": emptyIfNil(unchanged), }, nil) return nil }, }
DriveStatus walks --local-dir, recursively lists --folder-token, and reports four buckets (new_local, new_remote, modified, unchanged) either by exact SHA-256 hash (default) or by a quick modified_time comparison (--quick).
Only Drive entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped because there is no equivalent local binary to hash against.
SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any path that resolves outside cwd, which keeps the local side bounded to the caller's working directory.
var DriveSync = common.Shortcut{ Service: "drive", Command: "+sync", Description: "Two-way sync between a local directory and a Drive folder", Risk: "write", Scopes: []string{"drive:drive.metadata:readonly"}, ConditionalScopes: []string{ "drive:file:download", "drive:file:upload", "space:folder:create", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "Drive folder token", Required: true}, {Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}}, {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, {Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"}, }, Tips: []string{ "Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.", "Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.", "Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).", "Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.", "Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { return common.FlagErrorf("--local-dir is required") } if folderToken == "" { return common.FlagErrorf("--folder-token is required") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { return output.ErrValidation("%s", err) } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { return output.ErrValidation("%s", err) } info, err := runtime.FileIO().Stat(localDir) if err != nil { return common.WrapInputStatError(err) } if !info.IsDir() { return output.ErrValidation("--local-dir is not a directory: %s", localDir) } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy."). GET("/open-apis/drive/v1/files"). Set("folder_token", runtime.Str("folder-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) onConflict := strings.TrimSpace(runtime.Str("on-conflict")) if onConflict == "" { onConflict = driveSyncOnConflictRemoteWins } duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) if duplicateRemote == "" { duplicateRemote = driveDuplicateRemoteFail } quick := runtime.Bool("quick") if !quick { if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { return err } } safeRoot, err := validate.SafeInputPath(localDir) if err != nil { return output.ErrValidation("--local-dir: %s", err) } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { return output.ErrValidation("could not resolve cwd: %s", err) } rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { return output.ErrValidation("--local-dir resolves outside cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { return duplicateRemotePathError(duplicates) } // A local regular file at the same rel_path as a remote // folder/docx/shortcut is a type conflict: +sync would // classify it as new_local and attempt to upload, which either // fails at the API or leaves the remote in a broken state // (same rel_path with mixed types). Detect early and hard-fail. // Symmetrically, a local directory at the same rel_path as a // remote file/docx/shortcut would attempt create_folder and // produce the same broken mixed-type state. var typeConflicts []string for _, entry := range entries { if entry.Type == driveTypeFile { continue } if _, hasLocal := localFiles[entry.RelPath]; hasLocal { typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type)) } } for _, entry := range entries { if entry.Type == driveTypeFolder { continue } dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) if info, err := os.Stat(dirPath); err == nil && info.IsDir() { typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type)) } } if len(typeConflicts) > 0 { return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) } pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%s", err) } remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%s", err) } remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles) paths := mergeStatusPaths(localFiles, remoteFiles) var newLocal, newRemote, modified []driveStatusEntry var unchanged []driveStatusEntry for _, relPath := range paths { localFile, hasLocal := localFiles[relPath] remoteFile, hasRemote := remoteFiles[relPath] switch { case hasLocal && !hasRemote: newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) case !hasLocal && hasRemote: newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}) default: entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken} if quick { if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) { unchanged = append(unchanged, entry) } else { modified = append(modified, entry) } continue } localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd) if err != nil { return err } remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken) if err != nil { return err } if localHash == remoteHash { unchanged = append(unchanged, entry) } else { modified = append(modified, entry) } } } detection := driveStatusDetectionExact if quick { detection = driveStatusDetectionQuick } fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n", len(newLocal), len(newRemote), len(modified), len(unchanged), detection) conflictResolutions := make(map[string]string, len(modified)) if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil { return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist") } for _, entry := range modified { resolved := onConflict if resolved == driveSyncOnConflictAsk { resolved, err = driveSyncAskConflict(entry.RelPath, runtime) if err != nil { payload := map[string]interface{}{ "detection": detection, "diff": map[string]interface{}{ "new_local": emptyIfNil(newLocal), "new_remote": emptyIfNil(newRemote), "modified": emptyIfNil(modified), "unchanged": emptyIfNil(unchanged), }, "summary": map[string]interface{}{ "pulled": 0, "pushed": 0, "skipped": 0, "failed": 1, }, "items": []driveSyncItem{{ RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "conflict", Error: err.Error(), }}, } return &output.ExitError{ Code: output.ExitAPI, Detail: &output.ErrDetail{ Type: "partial_failure", Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err), Detail: payload, }, } } } conflictResolutions[entry.RelPath] = resolved } // --- Phase 2: Execute sync operations --- var pulled, pushed, skipped, failed int items := make([]driveSyncItem, 0) if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) { if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { return err } } plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions) if len(plannedUploads) > 0 { if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil { return err } } folderCache := map[string]string{"": folderToken} for relDir, entry := range remoteFolders { folderCache[relDir] = entry.FileToken } pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical) if err != nil { return err } if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) { if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil { return err } } for _, relDir := range localDirs { if _, alreadyRemote := folderCache[relDir]; alreadyRemote { continue } if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()}) failed++ continue } items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"}) pushed++ } for _, entry := range newRemote { targetFile, ok := pullRemoteFiles[entry.RelPath] if !ok { continue } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) failed++ continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) pulled++ } for _, entry := range newLocal { localFile, ok := pushLocalFiles[entry.RelPath] if !ok { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"}) skipped++ continue } parentRel := drivePushParentRel(entry.RelPath) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) if ensureErr != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()}) failed++ continue } token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) if upErr != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()}) failed++ continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"}) pushed++ } for _, entry := range modified { remoteFile := remoteFiles[entry.RelPath] localFile, hasLocal := pushLocalFiles[entry.RelPath] if !hasLocal { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"}) skipped++ continue } resolved := conflictResolutions[entry.RelPath] if resolved == "" { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"}) skipped++ continue } switch resolved { case driveSyncOnConflictRemoteWins: targetFile, ok := pullRemoteFiles[entry.RelPath] if !ok { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"}) failed++ continue } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) failed++ continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) pulled++ case driveSyncOnConflictLocalWins: existingToken := remoteFile.FileToken if existingToken == "" { if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok { existingToken = chosen.FileToken } } parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) if parentErr != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()}) failed++ continue } token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken) if upErr != nil { failedToken := token if failedToken == "" { failedToken = existingToken } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()}) failed++ continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"}) pushed++ case driveSyncOnConflictKeepBoth: occupied := occupiedRemotePaths(entries) for p := range pushLocalFiles { occupied[p] = struct{}{} } for _, relDir := range localDirs { occupied[relDir] = struct{}{} } suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied) if err != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()}) failed++ continue } oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel)) if err := os.Rename(oldAbsPath, newAbsPath); err != nil { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)}) failed++ continue } occupied[suffixedRel] = struct{}{} targetFile, ok := pullRemoteFiles[entry.RelPath] if !ok { rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) errMsg := "remote file not found in pull views after rename" if rollbackErr != nil { errMsg += "; rollback failed: " + rollbackErr.Error() } items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg}) failed++ continue } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) errMsg := err.Error() if rollbackErr != nil { errMsg += "; rollback failed: " + rollbackErr.Error() } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg}) failed++ continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"}) items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) pulled++ default: items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)}) skipped++ } } payload := map[string]interface{}{ "detection": detection, "diff": map[string]interface{}{ "new_local": emptyIfNil(newLocal), "new_remote": emptyIfNil(newRemote), "modified": emptyIfNil(modified), "unchanged": emptyIfNil(unchanged), }, "summary": map[string]interface{}{ "pulled": pulled, "pushed": pushed, "skipped": skipped, "failed": failed, }, "items": items, } if failed > 0 { msg := fmt.Sprintf("%d item(s) failed during +sync", failed) return &output.ExitError{ Code: output.ExitAPI, Detail: &output.ErrDetail{ Type: "partial_failure", Message: msg, Detail: payload, }, } } runtime.Out(payload, nil) return nil }, }
DriveSync performs a two-way sync between a local directory and a Drive folder. It computes a diff (like +status), then:
- new_remote → pull (download to local)
- new_local → push (upload to Drive)
- modified → resolve by --on-conflict strategy: local-wins: push local over remote; remote-wins: pull remote over local; keep-both: rename the local file with a hash suffix and pull the remote; ask: prompt the user per conflict.
var DriveTaskResult = common.Shortcut{ Service: "drive", Command: "+task_result", Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations", Risk: "read", Scopes: []string{}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false}, {Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false}, {Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true}, {Name: "file-token", Desc: "source document token used for export task status lookup", Required: false}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { scenario := strings.ToLower(runtime.Str("scenario")) validScenarios := map[string]bool{ "import": true, "export": true, "task_check": true, "wiki_move": true, "wiki_delete_space": true, "wiki_delete_node": true, } if !validScenarios[scenario] { return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario) } switch scenario { case "import", "export": if runtime.Str("ticket") == "" { return output.ErrValidation("--ticket is required for %s scenario", scenario) } if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil { return output.ErrValidation("%s", err) } case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node": if runtime.Str("task-id") == "" { return output.ErrValidation("--task-id is required for %s scenario", scenario) } if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil { return output.ErrValidation("%s", err) } } if scenario == "export" && runtime.Str("file-token") == "" { return output.ErrValidation("--file-token is required for export scenario") } if scenario == "export" { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { return output.ErrValidation("%s", err) } } return validateDriveTaskResultScopes(ctx, runtime, scenario) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { scenario := strings.ToLower(runtime.Str("scenario")) ticket := runtime.Str("ticket") taskID := runtime.Str("task-id") fileToken := runtime.Str("file-token") dry := common.NewDryRunAPI() dry.Desc(fmt.Sprintf("Poll async task result for %s scenario", scenario)) switch scenario { case "import": dry.GET("/open-apis/drive/v1/import_tasks/:ticket"). Desc("[1] Query import task result"). Set("ticket", ticket) case "export": dry.GET("/open-apis/drive/v1/export_tasks/:ticket"). Desc("[1] Query export task result"). Set("ticket", ticket). Params(map[string]interface{}{"token": fileToken}) case "task_check": dry.GET("/open-apis/drive/v1/files/task_check"). Desc("[1] Query move/delete folder task status"). Params(driveTaskCheckParams(taskID)) case "wiki_move": dry.GET("/open-apis/wiki/v2/tasks/:task_id"). Desc("[1] Query wiki move task result"). Set("task_id", taskID). Params(map[string]interface{}{"task_type": "move"}) case "wiki_delete_space": dry.GET("/open-apis/wiki/v2/tasks/:task_id"). Desc("[1] Query wiki delete-space task result"). Set("task_id", taskID). Params(map[string]interface{}{"task_type": "delete_space"}) case "wiki_delete_node": dry.GET("/open-apis/wiki/v2/tasks/:task_id"). Desc("[1] Query wiki delete-node task result"). Set("task_id", taskID). Params(map[string]interface{}{"task_type": "delete_node"}) } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { scenario := strings.ToLower(runtime.Str("scenario")) ticket := runtime.Str("ticket") taskID := runtime.Str("task-id") fileToken := runtime.Str("file-token") fmt.Fprintf(runtime.IO().ErrOut, "Querying %s task result...\n", scenario) var result map[string]interface{} var err error switch scenario { case "import": result, err = queryImportTaskAndAutoGrantPermission(runtime, ticket) case "export": result, err = queryExportTask(runtime, ticket, fileToken) case "task_check": result, err = queryTaskCheck(runtime, taskID) case "wiki_move": result, err = queryWikiMoveTask(runtime, taskID) case "wiki_delete_space": result, err = queryWikiDeleteSpaceTask(runtime, taskID) case "wiki_delete_node": result, err = queryWikiDeleteNodeTask(runtime, taskID) } if err != nil { return err } runtime.Out(result, nil) return nil }, }
DriveTaskResult exposes a unified read path for the async task types produced by Drive import, export, folder move/delete, wiki move, and wiki delete-space flows.
var DriveUpload = common.Shortcut{ Service: "drive", Command: "+upload", Description: "Upload a local file to Drive", Risk: "write", Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, {Name: "file-token", Desc: "existing file token to overwrite in place"}, {Name: "folder-token", Desc: "target folder token (default: root folder; mutually exclusive with --wiki-token)"}, {Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"}, {Name: "name", Desc: "uploaded file name (default: local file name)"}, }, Tips: []string{ "Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.", "Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.", "Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.", "In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime)) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := newDriveUploadSpec(runtime) target := spec.Target() isOverwrite := spec.FileToken != "" body := map[string]interface{}{ "file_name": spec.FileName(), "parent_type": target.ParentType, "parent_node": target.ParentNode, "file": "@" + spec.FilePath, } if spec.FileToken != "" { body["file_token"] = spec.FileToken } d := common.NewDryRunAPI(). Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata"). POST("/open-apis/drive/v1/files/upload_all"). Body(body) d.POST("/open-apis/drive/v1/metas/batch_query"). Desc("Fetch the uploaded file's real access URL"). Body(map[string]interface{}{ "request_docs": []map[string]interface{}{ { "doc_token": "<file_token from upload response>", "doc_type": "file", }, }, "with_url": true, }) if runtime.IsBot() && !isOverwrite { d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.") } return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := newDriveUploadSpec(runtime) isOverwrite := spec.FileToken != "" fileName := spec.FileName() target := spec.Target() info, err := runtime.FileIO().Stat(spec.FilePath) if err != nil { return common.WrapInputStatError(err) } fileSize := info.Size() fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> %s\n", fileName, common.FormatSize(fileSize), target.Label()) var uploadResult driveUploadResult if fileSize > common.MaxDriveMediaUploadSinglePartSize { fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") uploadResult, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken) } else { uploadResult, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken) } if err != nil { return err } out := map[string]interface{}{ "file_token": uploadResult.FileToken, "file_name": fileName, "size": fileSize, } if uploadResult.Version != "" { out["version"] = uploadResult.Version } if u, metaErr := common.FetchDriveMetaURL(runtime, uploadResult.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" { out["url"] = u } else if metaErr != nil { fmt.Fprintf(runtime.IO().ErrOut, "warning: uploaded file URL lookup failed: %v\n", metaErr) } if !isOverwrite { if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil { out["permission_grant"] = grant } } runtime.Out(out, nil) return nil }, }
var DriveVersionDelete = common.Shortcut{ Service: "drive", Command: "+version-delete", Description: "Delete a specific historical version of a Drive file", Risk: "high-risk-write", Scopes: []string{"drive:file:upload"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "target file token", Required: true}, {Name: "version", Desc: "version from drive +version-history to delete (not tag)", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveVersionMutationSpec(driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } return common.NewDryRunAPI(). Desc("Permanently delete a historical file version"). POST("/open-apis/drive/v1/files/:file_token/version_del"). Set("file_token", spec.FileToken). Body(map[string]interface{}{"version": spec.Version}) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } if _, err := runtime.CallAPI( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)), nil, map[string]interface{}{"version": spec.Version}, ); err != nil { return err } runtime.Out(map[string]interface{}{}, nil) return nil }, }
var DriveVersionGet = common.Shortcut{ Service: "drive", Command: "+version-get", Description: "Download a specific version of a Drive file", Risk: "read", Scopes: []string{"drive:file:download"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "file-token", Desc: "target file token", Required: true}, {Name: "version", Desc: "version from drive +version-history (not tag)", Required: true}, {Name: "output", Desc: "local save path or directory; omit to save into the current directory using the server filename"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveVersionGetSpec(runtime, driveVersionGetSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), Output: strings.TrimSpace(runtime.Str("output")), Overwrite: runtime.Bool("overwrite"), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveVersionGetSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), Output: strings.TrimSpace(runtime.Str("output")), } outputPath := spec.Output if outputPath == "" { outputPath = "." } return common.NewDryRunAPI(). Desc("Download a specific file version; when --output is omitted the CLI saves into the current directory using the server filename"). GET("/open-apis/drive/v1/files/:file_token/download"). Set("file_token", spec.FileToken). Set("output", outputPath). Params(map[string]interface{}{"version": spec.Version}) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveVersionGetSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), Output: strings.TrimSpace(runtime.Str("output")), Overwrite: runtime.Bool("overwrite"), } resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(spec.FileToken)), QueryParams: larkcore.QueryParams{ "version": []string{spec.Version}, }, }) if err != nil { return output.ErrNetwork("download failed: %s", err) } defer resp.Body.Close() fileName := common.ResolveDownloadFileName(resp.Header, spec.FileToken) fileName, _ = common.AutoAppendDownloadExtension(fileName, resp.Header, "") outputPath := spec.Output if outputPath == "" { outputPath = "." } if driveVersionGetOutputIsDirectory(runtime, outputPath) { outputPath = filepath.Join(outputPath, fileName) } else { outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "") } if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { return output.ErrValidation("unsafe output path: %s", resolveErr) } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite { return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) } result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body) if err != nil { return common.WrapSaveErrorByCategory(err, "io") } savedPath, _ := runtime.ResolveSavePath(outputPath) if savedPath == "" { savedPath = outputPath } out := map[string]interface{}{ "file_token": spec.FileToken, "version": spec.Version, "file_name": filepath.Base(outputPath), "saved_path": savedPath, "size_bytes": result.Size(), } runtime.OutFormat(out, nil, func(w io.Writer) { prettyPrintDriveVersionSavedFile(w, out) }) return nil }, }
var DriveVersionHistory = common.Shortcut{ Service: "drive", Command: "+version-history", Description: "List the version history of a Drive file", Risk: "read", Scopes: []string{"drive:file:download"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "file-token", Desc: "target file token", Required: true}, {Name: "limit", Desc: "max versions to return (1-200)", Type: "int", Default: "20"}, {Name: "cursor", Desc: "pagination cursor from the previous page's next_cursor"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveVersionHistorySpec(driveVersionHistorySpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Limit: runtime.Int("limit"), Cursor: strings.TrimSpace(runtime.Str("cursor")), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveVersionHistorySpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Limit: runtime.Int("limit"), Cursor: strings.TrimSpace(runtime.Str("cursor")), } return common.NewDryRunAPI(). Desc("Query version history with only_tag=true and optional pagination cursor"). GET("/open-apis/drive/v1/files/:file_token/history"). Set("file_token", spec.FileToken). Params(driveVersionHistoryParams(spec)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveVersionHistorySpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Limit: runtime.Int("limit"), Cursor: strings.TrimSpace(runtime.Str("cursor")), } data, err := runtime.CallAPI( http.MethodGet, fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)), driveVersionHistoryParams(spec), nil, ) if err != nil { return err } items := common.GetSlice(data, "items") hasMore := common.GetBool(data, "has_more") out := map[string]interface{}{ "versions": transformDriveVersionHistory(items), "has_more": hasMore, } if nextCursor := nextDriveVersionCursor(items, hasMore); nextCursor != "" { out["next_cursor"] = nextCursor } runtime.OutFormat(out, nil, nil) return nil }, }
var DriveVersionRevert = common.Shortcut{ Service: "drive", Command: "+version-revert", Description: "Revert a Drive file to a specific historical version", Risk: "write", Scopes: []string{"drive:file:upload"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file-token", Desc: "target file token", Required: true}, {Name: "version", Desc: "version from drive +version-history to revert to (not tag)", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveVersionMutationSpec(driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), }) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } return common.NewDryRunAPI(). Desc("Revert the current file to a specified historical version"). POST("/open-apis/drive/v1/files/:file_token/revert"). Set("file_token", spec.FileToken). Body(map[string]interface{}{"version": spec.Version}) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := driveVersionMutationSpec{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } if _, err := runtime.CallAPI( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)), nil, map[string]interface{}{"version": spec.Version}, ); err != nil { return err } runtime.Out(map[string]interface{}{}, nil) return nil }, }
Functions ¶
Types ¶
This section is empty.
Source Files
¶
- drive_add_comment.go
- drive_apply_permission.go
- drive_create_folder.go
- drive_create_shortcut.go
- drive_delete.go
- drive_download.go
- drive_export.go
- drive_export_common.go
- drive_export_download.go
- drive_import.go
- drive_import_common.go
- drive_inspect.go
- drive_move.go
- drive_move_common.go
- drive_pull.go
- drive_push.go
- drive_search.go
- drive_status.go
- drive_sync.go
- drive_task_result.go
- drive_upload.go
- drive_version.go
- list_remote.go
- shortcuts.go