Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
var DriveAddComment = common.Shortcut{ Service: "drive", Command: "+add-comment", Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides", Risk: "write", Scopes: []string{ "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, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true}, {Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "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 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) } 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}) } 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 to a local file with limited polling", Risk: "read", Scopes: []string{ "docs:document.content:read", "docs:document:export", "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", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}}, {Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}}, {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" { dr := common.NewDryRunAPI(). Desc("2-step orchestration: fetch docx markdown -> write local file"). GET("/open-apis/docs/v1/content"). Params(map[string]interface{}{ "doc_token": spec.Token, "doc_type": "docx", "content_type": "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)) data, err := runtime.CallAPI( "GET", "/open-apis/docs/v1/content", map[string]interface{}{ "doc_token": spec.Token, "doc_type": "docx", "content_type": "markdown", }, nil, ) if err != nil { return err } fileName := preferredFileName if fileName == "" { title, err := 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(common.GetString(data, "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([]byte(common.GetString(data, "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)"}, }, 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"), }) }, 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"), } fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec) if err != nil { return common.NewDryRunAPI().Set("error", err.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"), } 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 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", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}}, {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.", "--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 } 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 := listRemoteFolder(ctx, runtime, folderToken, "") if err != nil { return err } remoteFiles := make(map[string]string, len(entries)) remotePaths := make(map[string]struct{}, len(entries)) for rel, entry := range entries { remotePaths[rel] = struct{}{} if entry.Type == driveTypeFile { remoteFiles[rel] = entry.FileToken } } 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 { token := remoteFiles[rel] target := filepath.Join(rootRelToCwd, rel) if info, statErr := runtime.FileIO().Stat(target); statErr == nil { if info.IsDir() { items = append(items, drivePullItem{ RelPath: rel, FileToken: token, Action: "failed", Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target), }) failed++ downloadFailed++ continue } if ifExists == drivePullIfExistsSkip { items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"}) skipped++ continue } } if err := drivePullDownload(ctx, runtime, token, target); err != nil { items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()}) failed++ downloadFailed++ continue } items = append(items, drivePullItem{RelPath: rel, FileToken: token, 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 (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}}, {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.", "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.", "--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, overwrite (when --if-exists=overwrite) or skip existing, 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 } 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 := listRemoteFolder(ctx, runtime, folderToken, "") if err != nil { return err } remoteFiles := make(map[string]driveRemoteEntry, len(entries)) remoteFolders := make(map[string]driveRemoteEntry, len(entries)) for rel, entry := range entries { switch entry.Type { case driveTypeFile: remoteFiles[rel] = entry case driveTypeFolder: remoteFolders[rel] = entry } } 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 ifExists == drivePushIfExistsSkip { items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size}) skipped++ continue } token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken) 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(remoteFiles)) for p := range remoteFiles { remoteRelPaths = append(remoteRelPaths, p) } sort.Strings(remoteRelPaths) for _, rel := range remoteRelPaths { if _, ok := localFiles[rel]; ok { continue } entry := remoteFiles[rel] 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 created (uses current user's open_id)"}, {Name: "creator-ids", Desc: "comma-separated creator open_ids; 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 created\" filter. 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 content hash", Risk: "read", 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: "Drive folder token", Required: true}, }, Tips: []string{ "Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", "Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.", }, 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("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."). 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")) 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) localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) entries, err := listRemoteFolder(ctx, runtime, folderToken, "") if err != nil { return err } remoteFiles := make(map[string]string, len(entries)) for rel, entry := range entries { if entry.Type == driveTypeFile { remoteFiles[rel] = entry.FileToken } } paths := mergeStatusPaths(localHashes, remoteFiles) var newLocal, newRemote, modified, unchanged []driveStatusEntry for _, relPath := range paths { localHash, hasLocal := localHashes[relPath] remoteToken, hasRemote := remoteFiles[relPath] switch { case hasLocal && !hasRemote: newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) case !hasLocal && hasRemote: newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken}) default: remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken) if err != nil { return err } entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken} if localHash == remoteHash { unchanged = append(unchanged, entry) } else { modified = append(modified, entry) } } } runtime.Out(map[string]interface{}{ "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) by SHA-256 hash.
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 DriveTaskResult = common.Shortcut{ Service: "drive", Command: "+task_result", Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space 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, or wiki_delete_space tasks)", Required: false}, {Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", 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, } if !validScenarios[scenario] { return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", 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": 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"}) } 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) } 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"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, {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.", }, 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() d := common.NewDryRunAPI(). Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)"). POST("/open-apis/drive/v1/files/upload_all"). Body(map[string]interface{}{ "file_name": spec.FileName(), "parent_type": target.ParentType, "parent_node": target.ParentNode, "file": "@" + spec.FilePath, }) if runtime.IsBot() { d.Desc("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) 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 fileToken string if fileSize > common.MaxDriveMediaUploadSinglePartSize { fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") fileToken, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize) } else { fileToken, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize) } if err != nil { return err } out := map[string]interface{}{ "file_token": fileToken, "file_name": fileName, "size": fileSize, } if target.ParentType == driveUploadParentTypeExplorer { if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" { out["url"] = u } } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil { out["permission_grant"] = grant } runtime.Out(out, 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_move.go
- drive_move_common.go
- drive_pull.go
- drive_push.go
- drive_search.go
- drive_status.go
- drive_task_result.go
- drive_upload.go
- list_remote.go
- shortcuts.go