drive

package
v1.0.23 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 30, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
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
	},
}
View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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
	},
}
View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all drive shortcuts.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL