doc

package
v1.0.16 Latest Latest
Warning

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

Go to latest
Published: Apr 21, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Index

Constants

View Source
const PreviewType_SOURCE_FILE = "16"

Variables

View Source
var DocMediaDownload = common.Shortcut{
	Service:     "docs",
	Command:     "+media-download",
	Description: "Download document media or whiteboard thumbnail (auto-detects extension)",
	Risk:        "read",
	Scopes:      []string{"docs:document.media:download"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "token", Desc: "resource token (file_token or whiteboard_id)", Required: true},
		{Name: "output", Desc: "local save path", Required: true},
		{Name: "type", Default: "media", Desc: "resource type: media (default) | whiteboard"},
		{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		token := runtime.Str("token")
		outputPath := runtime.Str("output")
		mediaType := runtime.Str("type")
		if mediaType == "whiteboard" {
			return common.NewDryRunAPI().
				GET("/open-apis/board/v1/whiteboards/:token/download_as_image").
				Desc("(when --type=whiteboard) Download whiteboard as image").
				Set("token", token).Set("output", outputPath)
		}
		return common.NewDryRunAPI().
			GET("/open-apis/drive/v1/medias/:token/download").
			Desc("(when --type=media) Download document media file").
			Set("token", token).Set("output", outputPath)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		token := runtime.Str("token")
		outputPath := runtime.Str("output")
		mediaType := runtime.Str("type")
		overwrite := runtime.Bool("overwrite")

		if err := validate.ResourceName(token, "--token"); err != nil {
			return output.ErrValidation("%s", err)
		}
		if _, err := runtime.ResolveSavePath(outputPath); err != nil {
			return output.ErrValidation("unsafe output path: %s", err)
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token))

		encodedToken := validate.EncodePathSegment(token)
		var apiPath string
		if mediaType == "whiteboard" {
			apiPath = fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", encodedToken)
		} else {
			apiPath = fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", encodedToken)
		}

		resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
			HttpMethod: http.MethodGet,
			ApiPath:    apiPath,
		})
		if err != nil {
			return output.ErrNetwork("download failed: %v", err)
		}
		defer resp.Body.Close()

		fallbackExt := ""
		if mediaType == "whiteboard" {
			fallbackExt = ".png"
		}
		finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt)

		if finalPath != outputPath {
			if _, err := runtime.ResolveSavePath(finalPath); err != nil {
				return output.ErrValidation("unsafe output path: %s", err)
			}
		}

		if !overwrite {
			if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
				return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
			}
		}

		result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
			ContentType:   resp.Header.Get("Content-Type"),
			ContentLength: resp.ContentLength,
		}, resp.Body)
		if err != nil {
			return common.WrapSaveErrorByCategory(err, "io")
		}

		savedPath, _ := runtime.ResolveSavePath(finalPath)
		if savedPath == "" {
			savedPath = finalPath
		}
		runtime.Out(map[string]interface{}{
			"saved_path":   savedPath,
			"size_bytes":   result.Size(),
			"content_type": resp.Header.Get("Content-Type"),
		}, nil)
		return nil
	},
}
View Source
var DocMediaInsert = common.Shortcut{
	Service:     "docs",
	Command:     "+media-insert",
	Description: "Insert a local image or file into a Lark document (4-step orchestration + auto-rollback); appends to end by default, or inserts relative to a text selection with --selection-with-ellipsis",
	Risk:        "write",
	Scopes:      []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
		{Name: "doc", Desc: "document URL or document_id", Required: true},
		{Name: "type", Default: "image", Desc: "type: image | file"},
		{Name: "align", Desc: "alignment: left | center | right"},
		{Name: "caption", Desc: "image caption text"},
		{Name: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"},
		{Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"},
		{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		docRef, err := parseDocumentRef(runtime.Str("doc"))
		if err != nil {
			return err
		}
		if docRef.Kind == "doc" {
			return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
		}
		rawSelection := runtime.Str("selection-with-ellipsis")
		trimmedSelection := strings.TrimSpace(rawSelection)

		if rawSelection != "" && trimmedSelection == "" {
			return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
		}
		if runtime.Bool("before") && trimmedSelection == "" {
			return output.ErrValidation("--before requires --selection-with-ellipsis")
		}
		if view := runtime.Str("file-view"); view != "" {
			if _, ok := fileViewMap[view]; !ok {
				return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
			}
			if runtime.Str("type") != "file" {
				return output.ErrValidation("--file-view only applies when --type=file")
			}
		}
		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		docRef, err := parseDocumentRef(runtime.Str("doc"))
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}

		documentID := docRef.Token
		stepBase := 1
		filePath := runtime.Str("file")
		mediaType := runtime.Str("type")
		caption := runtime.Str("caption")
		selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis"))
		hasSelection := selection != ""
		fileViewType := fileViewMap[runtime.Str("file-view")]

		parentType := parentTypeForMediaType(mediaType)
		createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
		if hasSelection {
			createBlockData["index"] = "<locate_index>"
		} else {
			createBlockData["index"] = "<children_len>"
		}
		batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)

		d := common.NewDryRunAPI()
		totalSteps := 4
		if docRef.Kind == "wiki" {
			totalSteps++
		}
		if hasSelection {
			totalSteps++
		}

		positionLabel := map[bool]string{true: "before", false: "after"}[runtime.Bool("before")]

		if docRef.Kind == "wiki" {
			documentID = "<resolved_docx_token>"
			stepBase = 2
			d.Desc(fmt.Sprintf("%d-step orchestration: resolve wiki → query root →%s create block → upload file → bind to block (auto-rollback on failure)",
				totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection])).
				GET("/open-apis/wiki/v2/spaces/get_node").
				Desc("[1] Resolve wiki node to docx document").
				Params(map[string]interface{}{"token": docRef.Token})
		} else {
			d.Desc(fmt.Sprintf("%d-step orchestration: query root →%s create block → upload file → bind to block (auto-rollback on failure)",
				totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection]))
		}

		d.
			GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id").
			Desc(fmt.Sprintf("[%d] Get document root block", stepBase))

		if hasSelection {
			mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
			mcpArgs := map[string]interface{}{
				"doc_id":                  documentID,
				"selection_with_ellipsis": selection,
				"limit":                   1,
			}
			d.POST(mcpEndpoint).
				Desc(fmt.Sprintf("[%d] MCP locate-doc: find block matching selection (%s)", stepBase+1, positionLabel)).
				Body(map[string]interface{}{
					"method": "tools/call",
					"params": map[string]interface{}{
						"name":      "locate-doc",
						"arguments": mcpArgs,
					},
				}).
				Set("mcp_tool", "locate-doc").
				Set("args", mcpArgs)
			stepBase++
		}

		d.
			POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children").
			Desc(fmt.Sprintf("[%d] Create empty block at target position", stepBase+1)).
			Body(createBlockData)
		appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), filePath, parentType, stepBase+2)
		d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
			Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)).
			Body(batchUpdateData)

		return d.Set("document_id", documentID)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		filePath := runtime.Str("file")
		docInput := runtime.Str("doc")
		mediaType := runtime.Str("type")
		alignStr := runtime.Str("align")
		caption := runtime.Str("caption")
		fileViewType := fileViewMap[runtime.Str("file-view")]

		documentID, err := resolveDocxDocumentID(runtime, docInput)
		if err != nil {
			return err
		}

		stat, err := runtime.FileIO().Stat(filePath)
		if err != nil {
			return common.WrapInputStatError(err, "file not found")
		}
		if !stat.Mode().IsRegular() {
			return output.ErrValidation("file must be a regular file: %s", filePath)
		}

		fileName := filepath.Base(filePath)
		fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID))
		if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
			fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
		}

		rootData, err := runtime.CallAPI("GET",
			fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)),
			nil, nil)
		if err != nil {
			return err
		}

		parentBlockID, insertIndex, rootChildren, err := extractAppendTarget(rootData, documentID)
		if err != nil {
			return err
		}
		fmt.Fprintf(runtime.IO().ErrOut, "Root block ready: %s (%d children)\n", parentBlockID, insertIndex)

		selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis"))
		if selection != "" {
			before := runtime.Bool("before")

			fmt.Fprintf(runtime.IO().ErrOut, "Locating block matching selection (%s)\n", redactSelection(selection))
			idx, err := locateInsertIndex(runtime, documentID, selection, rootChildren, before)
			if err != nil {
				return err
			}
			insertIndex = idx
			posLabel := "after"
			if before {
				posLabel = "before"
			}
			fmt.Fprintf(runtime.IO().ErrOut, "locate-doc matched: inserting %s at index %d\n", posLabel, insertIndex)
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex)

		createData, err := runtime.CallAPI("POST",
			fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
			nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
		if err != nil {
			return err
		}

		blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType)

		if blockId == "" {
			return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned")
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId)
		if uploadParentNode != blockId || replaceBlockID != blockId {
			fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID)
		}

		rollback := func() error {
			fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
			_, err := runtime.CallAPI("DELETE",
				fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
				nil, buildDeleteBlockData(insertIndex))
			return err
		}
		withRollbackWarning := func(opErr error) error {
			rollbackErr := rollback()
			if rollbackErr == nil {
				return opErr
			}
			warning := fmt.Sprintf("rollback failed for block %s: %v", blockId, rollbackErr)
			fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
			return opErr
		}

		fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentTypeForMediaType(mediaType), uploadParentNode, documentID)
		if err != nil {
			return withRollbackWarning(err)
		}

		fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", fileToken)

		fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID)

		if _, err := runtime.CallAPI("PATCH",
			fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
			nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil {
			return withRollbackWarning(err)
		}

		runtime.Out(map[string]interface{}{
			"document_id": documentID,
			"block_id":    blockId,
			"file_token":  fileToken,
			"type":        mediaType,
		}, nil)
		return nil
	},
}
View Source
var DocMediaPreview = common.Shortcut{
	Service:     "docs",
	Command:     "+media-preview",
	Description: "Preview document media file (auto-detects extension)",
	Risk:        "read",
	Scopes:      []string{"docs:document.media:download"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "token", Desc: "media file token", Required: true},
		{Name: "output", Desc: "local save path", Required: true},
		{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		token := runtime.Str("token")
		outputPath := runtime.Str("output")
		return common.NewDryRunAPI().
			GET("/open-apis/drive/v1/medias/:token/preview_download").
			Desc("Preview document media file").
			Params(map[string]interface{}{"preview_type": PreviewType_SOURCE_FILE}).
			Set("token", token).Set("output", outputPath)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		token := runtime.Str("token")
		outputPath := runtime.Str("output")
		overwrite := runtime.Bool("overwrite")

		if err := validate.ResourceName(token, "--token"); err != nil {
			return output.ErrValidation("%s", err)
		}

		if _, err := runtime.ResolveSavePath(outputPath); err != nil {
			return output.ErrValidation("unsafe output path: %s", err)
		}

		fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token))

		encodedToken := validate.EncodePathSegment(token)
		apiPath := fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", encodedToken)

		resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
			HttpMethod: http.MethodGet,
			ApiPath:    apiPath,
			QueryParams: larkcore.QueryParams{
				"preview_type": []string{PreviewType_SOURCE_FILE},
			},
		})
		if err != nil {
			return output.ErrNetwork("preview failed: %v", err)
		}
		defer resp.Body.Close()

		finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")

		if finalPath != outputPath {
			if _, err := runtime.ResolveSavePath(finalPath); err != nil {
				return output.ErrValidation("unsafe output path: %s", err)
			}
		}

		if !overwrite {
			if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
				return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
			}
		}

		result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
			ContentType:   resp.Header.Get("Content-Type"),
			ContentLength: resp.ContentLength,
		}, resp.Body)
		if err != nil {
			return common.WrapSaveErrorByCategory(err, "io")
		}

		savedPath, _ := runtime.ResolveSavePath(finalPath)
		runtime.Out(map[string]interface{}{
			"saved_path":   savedPath,
			"size_bytes":   result.Size(),
			"content_type": resp.Header.Get("Content-Type"),
		}, nil)
		return nil
	},
}
View Source
var DocMediaUpload = common.Shortcut{
	Service:     "docs",
	Command:     "+media-upload",
	Description: "Upload media file (image/attachment) to a document block",
	Risk:        "write",
	Scopes:      []string{"docs:document.media:upload"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
		{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
		{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
		{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		filePath := runtime.Str("file")
		parentType := runtime.Str("parent-type")
		parentNode := runtime.Str("parent-node")
		docId := runtime.Str("doc-id")
		body := map[string]interface{}{
			"file_name":   filepath.Base(filePath),
			"parent_type": parentType,
			"parent_node": parentNode,
		}
		if docId != "" {
			body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
		}
		dry := common.NewDryRunAPI()
		if docMediaShouldUseMultipart(runtime.FileIO(), filePath) {
			prepareBody := map[string]interface{}{
				"file_name":   filepath.Base(filePath),
				"parent_type": parentType,
				"parent_node": parentNode,
				"size":        "<file_size>",
			}
			if extra, ok := body["extra"]; ok {
				prepareBody["extra"] = extra
			}
			dry.Desc("chunked media upload (files > 20MB)").
				POST("/open-apis/drive/v1/medias/upload_prepare").
				Body(prepareBody).
				POST("/open-apis/drive/v1/medias/upload_part").
				Body(map[string]interface{}{
					"upload_id": "<upload_id>",
					"seq":       "<chunk_index>",
					"size":      "<chunk_size>",
					"file":      "<chunk_binary>",
				}).
				POST("/open-apis/drive/v1/medias/upload_finish").
				Body(map[string]interface{}{
					"upload_id": "<upload_id>",
					"block_num": "<block_num>",
				})
			return dry
		}

		body["file"] = "@" + filePath
		body["size"] = "<file_size>"
		return dry.Desc("multipart/form-data upload").
			POST("/open-apis/drive/v1/medias/upload_all").
			Body(body)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		filePath := runtime.Str("file")
		parentType := runtime.Str("parent-type")
		parentNode := runtime.Str("parent-node")
		docId := runtime.Str("doc-id")

		stat, err := runtime.FileIO().Stat(filePath)
		if err != nil {
			return common.WrapInputStatError(err, "file not found")
		}
		if !stat.Mode().IsRegular() {
			return output.ErrValidation("file must be a regular file: %s", filePath)
		}

		fileName := filepath.Base(filePath)
		fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size())
		if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
			fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
		}

		fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentType, parentNode, docId)
		if err != nil {
			return err
		}

		runtime.Out(map[string]interface{}{
			"file_token": fileToken,
			"file_name":  fileName,
			"size":       stat.Size(),
		}, nil)
		return nil
	},
}
View Source
var DocsCreate = common.Shortcut{
	Service:     "docs",
	Command:     "+create",
	Description: "Create a Lark document",
	Risk:        "write",
	AuthTypes:   []string{"user", "bot"},
	Scopes:      []string{"docx:document:create"},
	Flags: []common.Flag{
		{Name: "title", Desc: "document title"},
		{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
		{Name: "folder-token", Desc: "parent folder token"},
		{Name: "wiki-node", Desc: "wiki node token"},
		{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		count := 0
		if runtime.Str("folder-token") != "" {
			count++
		}
		if runtime.Str("wiki-node") != "" {
			count++
		}
		if runtime.Str("wiki-space") != "" {
			count++
		}
		if count > 1 {
			return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
		}
		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		args := buildDocsCreateArgs(runtime)
		d := common.NewDryRunAPI().
			POST(common.MCPEndpoint(runtime.Config.Brand)).
			Desc("MCP tool: create-doc").
			Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
			Set("mcp_tool", "create-doc").Set("args", args)
		if runtime.IsBot() {
			d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
		}
		return d
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		args := buildDocsCreateArgs(runtime)
		result, err := common.CallMCPTool(runtime, "create-doc", args)
		if err != nil {
			return err
		}
		augmentDocsCreateResult(runtime, result)

		normalizeDocsUpdateResult(result, runtime.Str("markdown"))
		runtime.Out(result, nil)
		return nil
	},
}
View Source
var DocsFetch = common.Shortcut{
	Service:     "docs",
	Command:     "+fetch",
	Description: "Fetch Lark document content",
	Risk:        "read",
	Scopes:      []string{"docx:document:readonly"},
	AuthTypes:   []string{"user", "bot"},
	HasFormat:   true,
	Flags: []common.Flag{
		{Name: "doc", Desc: "document URL or token", Required: true},
		{Name: "offset", Desc: "pagination offset"},
		{Name: "limit", Desc: "pagination limit"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		args := map[string]interface{}{
			"doc_id": runtime.Str("doc"),

			"skip_task_detail": true,
		}
		if v := runtime.Str("offset"); v != "" {
			n, _ := strconv.Atoi(v)
			args["offset"] = n
		}
		if v := runtime.Str("limit"); v != "" {
			n, _ := strconv.Atoi(v)
			args["limit"] = n
		}
		return common.NewDryRunAPI().
			POST(common.MCPEndpoint(runtime.Config.Brand)).
			Desc("MCP tool: fetch-doc").
			Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
			Set("mcp_tool", "fetch-doc").Set("args", args)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		args := map[string]interface{}{
			"doc_id": runtime.Str("doc"),

			"skip_task_detail": true,
		}
		if v := runtime.Str("offset"); v != "" {
			n, _ := strconv.Atoi(v)
			args["offset"] = n
		}
		if v := runtime.Str("limit"); v != "" {
			n, _ := strconv.Atoi(v)
			args["limit"] = n
		}

		result, err := common.CallMCPTool(runtime, "fetch-doc", args)
		if err != nil {
			return err
		}

		if md, ok := result["markdown"].(string); ok {
			result["markdown"] = fixExportedMarkdown(md)
		}

		runtime.OutFormat(result, nil, func(w io.Writer) {
			if title, ok := result["title"].(string); ok && title != "" {
				fmt.Fprintf(w, "# %s\n\n", title)
			}
			if md, ok := result["markdown"].(string); ok {
				fmt.Fprintln(w, md)
			}
			if hasMore, ok := result["has_more"].(bool); ok && hasMore {
				fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
			}
		})
		return nil
	},
}
View Source
var DocsSearch = common.Shortcut{
	Service:     "docs",
	Command:     "+search",
	Description: "Search Lark docs, Wiki, and spreadsheet files (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"},
		{Name: "filter", Desc: "filter conditions (JSON object)"},
		{Name: "page-token", Desc: "page token"},
		{Name: "page-size", Default: "15", Desc: "page size (default 15, max 20)"},
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		requestData, err := buildDocsSearchRequest(
			runtime.Str("query"),
			runtime.Str("filter"),
			runtime.Str("page-token"),
			runtime.Str("page-size"),
		)
		if err != nil {
			return common.NewDryRunAPI().Set("error", err.Error())
		}

		return common.NewDryRunAPI().
			POST("/open-apis/search/v2/doc_wiki/search").
			Body(requestData)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		requestData, err := buildDocsSearchRequest(
			runtime.Str("query"),
			runtime.Str("filter"),
			runtime.Str("page-token"),
			runtime.Str("page-size"),
		)
		if err != nil {
			return err
		}

		data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
		if err != nil {
			return err
		}
		items, _ := data["res_units"].([]interface{})

		normalizedItems := addIsoTimeFields(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) {
			if len(normalizedItems) == 0 {
				fmt.Fprintln(w, "No matching results found.")
				return
			}

			htmlTagRe := regexp.MustCompile(`</?h>`)
			var rows []map[string]interface{}
			for _, item := range normalizedItems {
				u, _ := item.(map[string]interface{})
				if u == nil {
					continue
				}

				rawTitle := fmt.Sprintf("%v", u["title_highlighted"])
				title := htmlTagRe.ReplaceAllString(rawTitle, "")
				title = common.TruncateStr(title, 50)

				resultMeta, _ := u["result_meta"].(map[string]interface{})
				docTypes := ""
				if resultMeta != nil {
					docTypes = fmt.Sprintf("%v", resultMeta["doc_types"])
				}
				entityType := fmt.Sprintf("%v", u["entity_type"])
				typeStr := docTypes
				if typeStr == "" || typeStr == "<nil>" {
					typeStr = entityType
				}

				url := ""
				editTime := ""
				if resultMeta != nil {
					url = fmt.Sprintf("%v", resultMeta["url"])
					editTime = fmt.Sprintf("%v", resultMeta["update_time_iso"])
				}
				if len(url) > 80 {
					url = url[:80]
				}

				rows = append(rows, map[string]interface{}{
					"type":      typeStr,
					"title":     title,
					"edit_time": editTime,
					"url":       url,
				})
			}

			output.PrintTable(w, rows)
			moreHint := ""
			hasMore, _ := data["has_more"].(bool)
			if hasMore {
				moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)"
			}
			fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint)
		})
		return nil
	},
}
View Source
var DocsUpdate = common.Shortcut{
	Service:     "docs",
	Command:     "+update",
	Description: "Update a Lark document",
	Risk:        "write",
	Scopes:      []string{"docx:document:write_only", "docx:document:readonly"},
	AuthTypes:   []string{"user", "bot"},
	Flags: []common.Flag{
		{Name: "doc", Desc: "document URL or token", Required: true},
		{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
		{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
		{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
		{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
		{Name: "new-title", Desc: "also update document title"},
	},
	Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
		mode := runtime.Str("mode")
		if !validModes[mode] {
			return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
		}

		if mode != "delete_range" && runtime.Str("markdown") == "" {
			return common.FlagErrorf("--%s mode requires --markdown", mode)
		}

		selEllipsis := runtime.Str("selection-with-ellipsis")
		selTitle := runtime.Str("selection-by-title")
		if selEllipsis != "" && selTitle != "" {
			return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
		}

		if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
			return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
		}
		if err := validateSelectionByTitle(selTitle); err != nil {
			return err
		}

		return nil
	},
	DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
		args := map[string]interface{}{
			"doc_id": runtime.Str("doc"),
			"mode":   runtime.Str("mode"),
		}
		if v := runtime.Str("markdown"); v != "" {
			args["markdown"] = v
		}
		if v := runtime.Str("selection-with-ellipsis"); v != "" {
			args["selection_with_ellipsis"] = v
		}
		if v := runtime.Str("selection-by-title"); v != "" {
			args["selection_by_title"] = v
		}
		if v := runtime.Str("new-title"); v != "" {
			args["new_title"] = v
		}
		return common.NewDryRunAPI().
			POST(common.MCPEndpoint(runtime.Config.Brand)).
			Desc("MCP tool: update-doc").
			Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
			Set("mcp_tool", "update-doc").Set("args", args)
	},
	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
		mode := runtime.Str("mode")
		markdown := runtime.Str("markdown")

		for _, w := range docsUpdateWarnings(mode, markdown) {
			fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
		}

		args := map[string]interface{}{
			"doc_id": runtime.Str("doc"),
			"mode":   mode,
		}
		if markdown != "" {
			args["markdown"] = markdown
		}
		if v := runtime.Str("selection-with-ellipsis"); v != "" {
			args["selection_with_ellipsis"] = v
		}
		if v := runtime.Str("selection-by-title"); v != "" {
			args["selection_by_title"] = v
		}
		if v := runtime.Str("new-title"); v != "" {
			args["new_title"] = v
		}

		result, err := common.CallMCPTool(runtime, "update-doc", args)
		if err != nil {
			return err
		}

		normalizeDocsUpdateResult(result, runtime.Str("markdown"))
		runtime.Out(result, nil)
		return nil
	},
}

Functions

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all docs shortcuts.

Types

This section is empty.

Jump to

Keyboard shortcuts

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